super-productivity/src/app/op-log/sync/conflict-resolution.service.spec.ts
Johannes Millan cbeb7c704f
fix(sync): name the discarded title in LWW conflict banner + fix fr dismiss label (#8694) (#8724)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(sync): name discarded title in LWW conflict banner; fix fr dismiss label (#8694)

* fix(sync): surface the last discarded title in LWW conflict banner

Multi-review follow-up to the #8694 banner:
- Show the LAST non-empty discarded title (the user's final offline rename),
  not the first — a stale intermediate rename is misleading. Also simpler
  (drops the first-wins guard).
- Document why the kept==discarded equality guard is correct: when a title
  edit loses to a concurrent other-field remote win, the current state still
  shows the rejected local title, so an annotation would only repeat it —
  silencing it is right, not a hidden divergence.

fr.json G.DISMISS "Rejeter"->"Ignorer" (prior commit) is an intentional,
owner-approved deviation from the en-only rule: it is a mistranslation fix
for the shared banner dismiss label, not a new string. Other locales may
carry the same "Reject"-flavored label — left as a follow-up.
2026-07-03 14:13:43 +02:00

4096 lines
141 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { ConflictResolutionService } from './conflict-resolution.service';
import { Store } from '@ngrx/store';
import { OperationApplierService } from '../apply/operation-applier.service';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { SnackService } from '../../core/snack/snack.service';
import { BannerService } from '../../core/banner/banner.service';
import { BannerId } from '../../core/banner/banner.model';
import { ValidateStateService } from '../validation/validate-state.service';
import { of } from 'rxjs';
import { ActionType, EntityConflict, OpType, Operation } from '../core/operation.types';
import { VectorClock, VectorClockComparison } from '../../core/util/vector-clock';
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
import { MAX_VECTOR_CLOCK_SIZE } from '../core/operation-log.const';
import { buildEntityRegistry, ENTITY_REGISTRY } from '../core/entity-registry';
import { OperationLogEffects } from '../capture/operation-log.effects';
describe('ConflictResolutionService', () => {
let service: ConflictResolutionService;
let mockStore: jasmine.SpyObj<Store>;
let mockOperationApplier: jasmine.SpyObj<OperationApplierService>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockValidateStateService: jasmine.SpyObj<ValidateStateService>;
let mockClientIdProvider: { loadClientId: jasmine.Spy };
let mockEntityRegistry: ReturnType<typeof buildEntityRegistry>;
let mockOperationLogEffects: jasmine.SpyObj<OperationLogEffects>;
const TEST_CLIENT_ID = 'test-client-123';
const createMockOp = (id: string, clientId: string): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
// Use different payloads for different clients to avoid auto-resolution as identical
payload: { source: clientId },
vectorClock: { [clientId]: 1 },
timestamp: Date.now(),
schemaVersion: 1,
});
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['select']);
// Default: select returns of(undefined) - can be overridden in specific tests
mockStore.select.and.returnValue(of(undefined));
mockOperationApplier = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'hasOp',
'append',
'appendBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockValidateStateService = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
mockOperationLogEffects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
mockClientIdProvider = {
loadClientId: jasmine
.createSpy('loadClientId')
.and.returnValue(Promise.resolve(TEST_CLIENT_ID)),
};
mockEntityRegistry = buildEntityRegistry();
TestBed.configureTestingModule({
providers: [
ConflictResolutionService,
{ provide: Store, useValue: mockStore },
{ provide: OperationApplierService, useValue: mockOperationApplier },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: SnackService, useValue: mockSnackService },
{ provide: ValidateStateService, useValue: mockValidateStateService },
{ provide: OperationLogEffects, useValue: mockOperationLogEffects },
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
{ provide: ENTITY_REGISTRY, useValue: mockEntityRegistry },
],
});
service = TestBed.inject(ConflictResolutionService);
// Default mock behaviors
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
mockOperationLogEffects.processDeferredActions.and.resolveTo();
mockValidateStateService.validateAndRepairCurrentState.and.resolveTo(true);
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
// By default, appendBatchSkipDuplicates writes all ops (no duplicates)
mockOpLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
}),
);
});
describe('getCurrentEntityState', () => {
it('should use injected registry selector factory for ISSUE_PROVIDER', async () => {
const expectedEntity = { id: 'issue-provider-1' };
const selector = (_state: object): unknown => expectedEntity;
const selectorFactoryCalls: Array<[string, null]> = [];
const selectorFactory = (id: string, key: null): ((state: object) => unknown) => {
selectorFactoryCalls.push([id, key]);
return selector;
};
const issueProviderConfig = mockEntityRegistry.ISSUE_PROVIDER;
expect(issueProviderConfig).toBeDefined();
if (!issueProviderConfig) {
return;
}
issueProviderConfig.selectById = selectorFactory;
mockStore.select.and.returnValue(of(expectedEntity));
const result = await service.getCurrentEntityState(
'ISSUE_PROVIDER',
'issue-provider-1',
);
expect(result).toBe(expectedEntity);
expect(selectorFactoryCalls).toEqual([['issue-provider-1', null]]);
expect(mockStore.select.calls.mostRecent().args[0]).toBe(selector);
});
});
describe('isIdenticalConflict', () => {
it('should detect identical conflict when both sides DELETE', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [{ ...createMockOp('local-1', 'local'), opType: OpType.Delete }],
remoteOps: [{ ...createMockOp('remote-1', 'remote'), opType: OpType.Delete }],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
it('should detect identical conflict when both sides have same UPDATE payload', () => {
const payload = { title: 'Same Title', notes: 'Same Notes' };
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{ ...createMockOp('local-1', 'local'), opType: OpType.Update, payload },
],
remoteOps: [
{ ...createMockOp('remote-1', 'remote'), opType: OpType.Update, payload },
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
it('should NOT detect identical conflict when payloads differ', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createMockOp('local-1', 'local'),
opType: OpType.Update,
payload: { title: 'Local Title' },
},
],
remoteOps: [
{
...createMockOp('remote-1', 'remote'),
opType: OpType.Update,
payload: { title: 'Remote Title' },
},
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
it('should NOT detect identical conflict when opTypes differ', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [{ ...createMockOp('local-1', 'local'), opType: OpType.Update }],
remoteOps: [{ ...createMockOp('remote-1', 'remote'), opType: OpType.Delete }],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
it('should NOT detect identical conflict when multiple ops with different counts', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{ ...createMockOp('local-1', 'local'), opType: OpType.Update },
{ ...createMockOp('local-2', 'local'), opType: OpType.Update },
],
remoteOps: [{ ...createMockOp('remote-1', 'remote'), opType: OpType.Update }],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
it('should detect identical conflict with multiple DELETE ops on both sides', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{ ...createMockOp('local-1', 'local'), opType: OpType.Delete },
{ ...createMockOp('local-2', 'local'), opType: OpType.Delete },
],
remoteOps: [
{ ...createMockOp('remote-1', 'remote'), opType: OpType.Delete },
{ ...createMockOp('remote-2', 'remote'), opType: OpType.Delete },
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
it('should handle nested object payloads correctly', () => {
const payload = {
title: 'Test',
nested: { deep: { value: 123 }, array: [1, 2, 3] },
};
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{ ...createMockOp('local-1', 'local'), opType: OpType.Update, payload },
],
remoteOps: [
{ ...createMockOp('remote-1', 'remote'), opType: OpType.Update, payload },
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
it('should return false for empty ops', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [],
remoteOps: [{ ...createMockOp('remote-1', 'remote'), opType: OpType.Delete }],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
});
describe('isIdenticalConflict edge cases', () => {
it('should NOT detect arrays with different order as identical', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createMockOp('local-1', 'local'),
opType: OpType.Update,
payload: { tagIds: ['a', 'b', 'c'] },
},
],
remoteOps: [
{
...createMockOp('remote-1', 'remote'),
opType: OpType.Update,
payload: { tagIds: ['c', 'b', 'a'] },
},
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
it('should handle null payload values correctly', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createMockOp('local-1', 'local'),
opType: OpType.Update,
payload: { notes: null },
},
],
remoteOps: [
{
...createMockOp('remote-1', 'remote'),
opType: OpType.Update,
payload: { notes: null },
},
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
it('should NOT treat null and undefined as identical', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createMockOp('local-1', 'local'),
opType: OpType.Update,
payload: { notes: null },
},
],
remoteOps: [
{
...createMockOp('remote-1', 'remote'),
opType: OpType.Update,
payload: { notes: undefined },
},
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(false);
});
it('should handle empty objects as identical', () => {
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createMockOp('local-1', 'local'),
opType: OpType.Update,
payload: {},
},
],
remoteOps: [
{
...createMockOp('remote-1', 'remote'),
opType: OpType.Update,
payload: {},
},
],
suggestedResolution: 'manual',
};
expect(service.isIdenticalConflict(conflict)).toBe(true);
});
});
describe('autoResolveConflictsLWW', () => {
// Helper to create ops with specific timestamps
const createOpWithTimestamp = (
id: string,
clientId: string,
timestamp: number,
opType: OpType = OpType.Update,
entityId: string = 'task-1',
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType,
entityType: 'TASK',
entityId,
payload: { source: clientId, timestamp },
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
// Helper to create conflict with suggestedResolution
const createConflict = (
entityId: string,
localOps: Operation[],
remoteOps: Operation[],
): EntityConflict => ({
entityType: 'TASK',
entityId,
localOps,
remoteOps,
suggestedResolution: 'manual', // LWW will override this
});
beforeEach(() => {
// Default mock behaviors for LWW tests
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
});
it('should auto-resolve conflict as remote when remote timestamp is newer', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote ops should be appended via batch
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
[jasmine.objectContaining({ id: 'remote-1' })],
'remote',
jasmine.any(Object),
);
// Local ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
// Snack should be shown
expect(mockSnackService.open).toHaveBeenCalled();
});
it('should auto-resolve conflict as local when local timestamp is newer', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Both local and remote ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
// Remote ops need to be appended first (via batch), then rejected
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
[jasmine.objectContaining({ id: 'remote-1' })],
'remote',
undefined,
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
// Snack should show local wins
expect(mockSnackService.open).toHaveBeenCalled();
});
it('shows a content-conflict banner (not the generic snack) when a discarded edit touched task content (#8694)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
const now = Date.now();
// Remote wins (newer) -> the local title edit is discarded = content loss.
// Use the real captured payload shape: { actionPayload, entityChanges: [] }.
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-1', 'client-a', now - 1000),
payload: {
actionPayload: {
task: { id: 'task-1', changes: { title: 'My local title' } },
},
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-1', 'client-b', now),
payload: {
actionPayload: {
task: { id: 'task-1', changes: { title: 'Remote title' } },
},
entityChanges: [],
},
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
expect(openBannerSpy).toHaveBeenCalledWith(
jasmine.objectContaining({ id: BannerId.SyncConflictContentResolved }),
);
// The generic "N local/remote wins" count snack must NOT fire for content loss.
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('escapes task titles before putting them in the innerHTML banner (XSS guard)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
// The banner renders msg via [innerHTML]; a title synced from another
// device must not be able to inject markup.
mockStore.select.and.returnValue(of({ title: '<img src=x onerror=alert(1)>' }));
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-1', 'client-a', now - 1000),
payload: {
actionPayload: { task: { id: 'task-1', changes: { notes: 'edited' } } },
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-1', 'client-b', now),
payload: {
actionPayload: { task: { id: 'task-1', changes: { notes: 'other' } } },
entityChanges: [],
},
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
const bannerArg = openBannerSpy.calls.mostRecent().args[0];
const taskList = bannerArg.translateParams?.taskList as string;
expect(taskList).not.toContain('<img');
expect(taskList).toContain('&lt;img');
});
it('names the discarded title when the title itself conflicted (#8694 review)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
// Kept (current) title comes from the store = the winning value; the
// discarded value must still be surfaced so double-check is actionable.
mockStore.select.and.returnValue(of({ title: 'Remote title' }));
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-1', 'client-a', now - 1000),
payload: {
actionPayload: {
task: { id: 'task-1', changes: { title: 'My local title' } },
},
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-1', 'client-b', now),
payload: {
actionPayload: {
task: { id: 'task-1', changes: { title: 'Remote title' } },
},
entityChanges: [],
},
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
const taskList = openBannerSpy.calls.mostRecent().args[0].translateParams
?.taskList as string;
expect(taskList).toContain('"Remote title"');
expect(taskList).toContain('"My local title"');
});
it('escapes the discarded title too (XSS guard on the new field)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
mockStore.select.and.returnValue(of({ title: 'Kept' }));
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-1', 'client-a', now - 1000),
payload: {
actionPayload: {
task: {
id: 'task-1',
changes: { title: '<img src=x onerror=alert(1)>' },
},
},
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-1', 'client-b', now),
payload: {
actionPayload: { task: { id: 'task-1', changes: { title: 'Kept' } } },
entityChanges: [],
},
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
const taskList = openBannerSpy.calls.mostRecent().args[0].translateParams
?.taskList as string;
expect(taskList).not.toContain('<img');
expect(taskList).toContain('&lt;img');
});
it('keeps the quiet count snack (no banner) for routine-only resolutions (#8694)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
const now = Date.now();
// Remote wins -> discarded local op only rescheduled (dueDay) = routine.
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-1', 'client-a', now - 1000),
payload: {
actionPayload: {
task: { id: 'task-1', changes: { dueDay: '2026-07-02' } },
},
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-1', 'client-b', now),
payload: {
actionPayload: { task: { id: 'task-1', changes: { dueDay: null } } },
entityChanges: [],
},
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
expect(mockSnackService.open).toHaveBeenCalled();
expect(openBannerSpy).not.toHaveBeenCalled();
});
it('should auto-resolve as remote when timestamps are equal (tie-breaker)', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote wins on tie - should apply remote ops via batch
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
[jasmine.objectContaining({ id: 'remote-1' })],
'remote',
jasmine.any(Object),
);
// Local ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
});
it('should not duplicate already rejected local ops while adding superseded pending ops', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(
new Map([
[
'TASK:task-1',
[
createOpWithTimestamp('local-1', 'client-a', now - 1000),
createOpWithTimestamp('local-2', 'client-a', now - 500),
],
],
]),
);
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1', 'local-2']);
});
it('should handle multiple conflicts in single batch', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
createOpWithTimestamp(
'local-1',
'client-a',
now - 1000,
OpType.Update,
'task-1',
),
],
[createOpWithTimestamp('remote-1', 'client-b', now, OpType.Update, 'task-1')],
),
createConflict(
'task-2',
[createOpWithTimestamp('local-2', 'client-a', now, OpType.Update, 'task-2')],
[
createOpWithTimestamp(
'remote-2',
'client-b',
now - 1000,
OpType.Update,
'task-2',
),
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflicts[0].remoteOps[0]],
});
await service.autoResolveConflictsLWW(conflicts);
// First conflict: remote wins (newer timestamp) - goes to pendingApply batch
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-1' })]),
'remote',
jasmine.any(Object),
);
// Second conflict: local wins (newer timestamp)
// Remote op should be appended (via batch) then rejected
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-2' })]),
'remote',
undefined,
);
// Snack notification should reflect both outcomes
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: {
localWins: 1,
remoteWins: 1,
},
}),
);
});
it('should piggyback non-conflicting ops with conflict resolution', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
const nonConflicting = [
createOpWithTimestamp('non-conflict-1', 'client-b', now, OpType.Update, 'task-2'),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [...conflicts[0].remoteOps, ...nonConflicting],
});
await service.autoResolveConflictsLWW(conflicts, nonConflicting);
// Non-conflicting op should also be appended (via batch with the remote-wins ops)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'non-conflict-1' })]),
'remote',
jasmine.any(Object),
);
});
it('should return early if no conflicts and no non-conflicting ops', async () => {
await service.autoResolveConflictsLWW([]);
expect(mockOpLogStore.appendBatchSkipDuplicates).not.toHaveBeenCalled();
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should validate state after resolution', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
expect(mockValidateStateService.validateAndRepairCurrentState).toHaveBeenCalled();
});
it('should use max timestamp when multiple ops exist on one side', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
// Local has older first op but newer second op
[
createOpWithTimestamp('local-1', 'client-a', now - 2000),
createOpWithTimestamp('local-2', 'client-a', now),
],
// Remote has one op in between
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Local wins because max(local timestamps) > max(remote timestamps)
// Both local ops should be rejected (old ones replaced by new update op)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1', 'local-2']);
// Remote ops should be appended then rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
});
describe('edge cases', () => {
it('should handle conflict with empty localOps array gracefully', async () => {
// Edge case: conflict struct with empty localOps (shouldn't happen but defensive)
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [], // Empty!
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', now)],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
// Math.max() with empty array returns -Infinity, so remote should always win
await expectAsync(service.autoResolveConflictsLWW(conflicts)).toBeResolved();
// Remote should win (any timestamp > -Infinity)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-1' })]),
'remote',
jasmine.any(Object),
);
});
it('should handle conflict with empty remoteOps array gracefully', async () => {
// Edge case: conflict struct with empty remoteOps (shouldn't happen but defensive)
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [], // Empty!
suggestedResolution: 'manual',
},
];
// Math.max() with empty array returns -Infinity, so local should always win
await expectAsync(service.autoResolveConflictsLWW(conflicts)).toBeResolved();
// Local wins (any timestamp > -Infinity)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
// No remote ops to append
});
it('should resolve DELETE vs UPDATE conflict using LWW when DELETE is newer', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', now),
opType: OpType.Delete,
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', now - 1000),
opType: OpType.Update,
},
],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Local DELETE wins (newer timestamp)
// Both ops should be rejected, local state (deleted) is preserved
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-del']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
});
it('should resolve UPDATE vs DELETE conflict using LWW when UPDATE is newer', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-upd', 'client-a', now),
opType: OpType.Update,
},
],
[
{
...createOpWithTimestamp('remote-del', 'client-b', now - 1000),
opType: OpType.Delete,
},
],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Local UPDATE wins (newer timestamp)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-upd']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-del']);
});
it('should resolve DELETE vs UPDATE conflict when DELETE is older (remote UPDATE wins)', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', now - 1000),
opType: OpType.Delete,
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', now),
opType: OpType.Update,
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote UPDATE wins (newer timestamp) - entity should be restored
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([
jasmine.objectContaining({ id: 'remote-upd', opType: OpType.Update }),
]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-del']);
});
it('should extract entity from DELETE payload when UPDATE wins but entity not in store', () => {
// This tests the helper method that extracts entity state from DELETE operations
// Used when remote DELETE is applied first, then local UPDATE wins LWW
const taskEntity = {
id: 'task-1',
title: 'Test Task',
projectId: 'project-1',
tagIds: [],
};
const conflict: EntityConflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-upd', 'client-a', Date.now()),
opType: OpType.Update,
payload: { task: taskEntity },
},
],
[
{
...createOpWithTimestamp('remote-del', 'client-b', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: taskEntity }, // DELETE payload contains the deleted entity
},
],
);
// Call the private extraction method
const extractedEntity = (service as any)._extractEntityFromDeleteOperation(
conflict,
);
// Verify it extracted the entity from the DELETE operation's payload
expect(extractedEntity).toEqual(taskEntity);
});
it('should return undefined when no DELETE operation in conflict', () => {
const conflict: EntityConflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-upd', 'client-a', Date.now()),
opType: OpType.Update,
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now() - 1000),
opType: OpType.Update,
},
],
);
const extractedEntity = (service as any)._extractEntityFromDeleteOperation(
conflict,
);
expect(extractedEntity).toBeUndefined();
});
it('should handle CREATE vs CREATE conflict using LWW', async () => {
// Two clients create entity with same ID (rare but possible)
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-same-id',
[
{
...createOpWithTimestamp('local-create', 'client-a', now - 1000),
opType: OpType.Create,
entityId: 'task-same-id',
payload: { title: 'Local Version' },
},
],
[
{
...createOpWithTimestamp('remote-create', 'client-b', now),
opType: OpType.Create,
entityId: 'task-same-id',
payload: { title: 'Remote Version' },
},
],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote CREATE wins (newer timestamp)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([
jasmine.objectContaining({ id: 'remote-create', opType: OpType.Create }),
]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-create']);
});
it('should handle MOV (Move) operation conflicts using LWW', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createOpWithTimestamp('local-mov', 'client-a', now),
opType: OpType.Move,
payload: { fromIndex: 0, toIndex: 5 },
},
],
remoteOps: [
{
...createOpWithTimestamp('remote-mov', 'client-b', now - 1000),
opType: OpType.Move,
payload: { fromIndex: 0, toIndex: 10 },
},
],
suggestedResolution: 'manual',
},
];
await service.autoResolveConflictsLWW(conflicts);
// Local MOV wins (newer timestamp)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-mov']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-mov']);
});
it('should handle BATCH operation conflicts using LWW', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
...createOpWithTimestamp('local-batch', 'client-a', now - 1000),
opType: OpType.Batch,
entityIds: ['task-1', 'task-2', 'task-3'],
payload: { changes: [{ done: true }] },
},
],
remoteOps: [
{
...createOpWithTimestamp('remote-batch', 'client-b', now),
opType: OpType.Batch,
entityIds: ['task-1', 'task-2'],
payload: { changes: [{ done: false }] },
},
],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote BATCH wins (newer timestamp)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([
jasmine.objectContaining({ id: 'remote-batch', opType: OpType.Batch }),
]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-batch']);
});
it('should handle singleton entity (GLOBAL_CONFIG) conflicts', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'GLOBAL_CONFIG',
entityId: 'GLOBAL_CONFIG', // Singleton ID
localOps: [
{
id: 'local-config',
clientId: 'clientA',
actionType: 'updateGlobalConfig' as ActionType,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'GLOBAL_CONFIG',
payload: { theme: 'dark' },
vectorClock: { clientA: 1 },
timestamp: now,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-config',
clientId: 'clientB',
actionType: 'updateGlobalConfig' as ActionType,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'GLOBAL_CONFIG',
payload: { theme: 'light' },
vectorClock: { clientB: 1 },
timestamp: now - 1000,
schemaVersion: 1,
},
],
suggestedResolution: 'manual',
},
];
await service.autoResolveConflictsLWW(conflicts);
// Local wins (newer timestamp)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-config']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-config']);
});
it('should handle PLANNER entity conflicts', async () => {
const now = Date.now();
const dayId = '2024-01-15';
const conflicts: EntityConflict[] = [
{
entityType: 'PLANNER',
entityId: dayId,
localOps: [
{
id: 'local-planner',
clientId: 'clientA',
actionType: 'updatePlanner' as ActionType,
opType: OpType.Update,
entityType: 'PLANNER',
entityId: dayId,
payload: { scheduledTaskIds: ['task-1', 'task-2'] },
vectorClock: { clientA: 1 },
timestamp: now - 1000,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-planner',
clientId: 'clientB',
actionType: 'updatePlanner' as ActionType,
opType: OpType.Update,
entityType: 'PLANNER',
entityId: dayId,
payload: { scheduledTaskIds: ['task-3', 'task-4'] },
vectorClock: { clientB: 1 },
timestamp: now,
schemaVersion: 1,
},
],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote wins (newer timestamp)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-planner' })]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-planner']);
});
it('should handle BOARD entity conflicts', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'BOARD',
entityId: 'board-1',
localOps: [
{
id: 'local-board',
clientId: 'clientA',
actionType: 'updateBoard' as ActionType,
opType: OpType.Update,
entityType: 'BOARD',
entityId: 'board-1',
payload: { title: 'Local Board' },
vectorClock: { clientA: 1 },
timestamp: now,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-board',
clientId: 'clientB',
actionType: 'updateBoard' as ActionType,
opType: OpType.Update,
entityType: 'BOARD',
entityId: 'board-1',
payload: { title: 'Remote Board' },
vectorClock: { clientB: 1 },
timestamp: now - 1000,
schemaVersion: 1,
},
],
suggestedResolution: 'manual',
},
];
await service.autoResolveConflictsLWW(conflicts);
// Local wins (newer timestamp)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-board']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-board']);
});
it('should handle REMINDER entity conflicts', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'REMINDER',
entityId: 'reminder-1',
localOps: [
{
id: 'local-reminder',
clientId: 'clientA',
actionType: 'updateReminder' as ActionType,
opType: OpType.Update,
entityType: 'REMINDER',
entityId: 'reminder-1',
payload: { title: 'Local Reminder', remindAt: now + 3600000 },
vectorClock: { clientA: 1 },
timestamp: now - 500,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-reminder',
clientId: 'clientB',
actionType: 'updateReminder' as ActionType,
opType: OpType.Update,
entityType: 'REMINDER',
entityId: 'reminder-1',
payload: { title: 'Remote Reminder', remindAt: now + 7200000 },
vectorClock: { clientB: 1 },
timestamp: now,
schemaVersion: 1,
},
],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote wins (newer timestamp)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-reminder' })]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-reminder']);
});
// Regression test: PLUGIN_USER_DATA was previously registered as
// `'virtual'` and silently bypassed LWW. The migration to `'array'`
// (entity-registry.ts:325-330) wired it through the array branch;
// this test asserts the wiring actually fires for real conflicts.
it('should handle PLUGIN_USER_DATA entity conflicts', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'PLUGIN_USER_DATA',
entityId: 'doc-mode',
localOps: [
{
id: 'local-plugin-data',
clientId: 'clientA',
actionType: '[Plugin] Upsert User Data' as ActionType,
opType: OpType.Update,
entityType: 'PLUGIN_USER_DATA',
entityId: 'doc-mode',
payload: { id: 'doc-mode', data: 'local-blob' },
vectorClock: { clientA: 1 },
timestamp: now - 500,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-plugin-data',
clientId: 'clientB',
actionType: '[Plugin] Upsert User Data' as ActionType,
opType: OpType.Update,
entityType: 'PLUGIN_USER_DATA',
entityId: 'doc-mode',
payload: { id: 'doc-mode', data: 'remote-blob' },
vectorClock: { clientB: 1 },
timestamp: now,
schemaVersion: 1,
},
],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote wins (newer timestamp). Whole-blob LWW: this is the
// gap Stage A closes — fine for same-entity, lossy across
// different sub-keys of the same blob. The acceptance is in the
// re-bundling decision, not in this test.
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([
jasmine.objectContaining({ id: 'remote-plugin-data' }),
]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-plugin-data']);
});
it('should handle mixed entity types in conflicts batch', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-task', 'client-a', now - 1000)],
remoteOps: [createOpWithTimestamp('remote-task', 'client-b', now)],
suggestedResolution: 'manual',
},
{
entityType: 'PROJECT',
entityId: 'project-1',
localOps: [
{
...createOpWithTimestamp('local-project', 'client-a', now),
entityType: 'PROJECT',
entityId: 'project-1',
},
],
remoteOps: [
{
...createOpWithTimestamp('remote-project', 'client-b', now - 1000),
entityType: 'PROJECT',
entityId: 'project-1',
},
],
suggestedResolution: 'manual',
},
{
entityType: 'TAG',
entityId: 'tag-1',
localOps: [
{
...createOpWithTimestamp('local-tag', 'client-a', now - 500),
entityType: 'TAG',
entityId: 'tag-1',
},
],
remoteOps: [
{
...createOpWithTimestamp('remote-tag', 'client-b', now - 500),
entityType: 'TAG',
entityId: 'tag-1',
},
],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflicts[0].remoteOps[0], conflicts[2].remoteOps[0]],
});
await service.autoResolveConflictsLWW(conflicts);
// Task: remote wins (newer), Tag: remote wins (tie goes to remote)
// Both are appended in a single batch
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([
jasmine.objectContaining({ id: 'remote-task' }),
jasmine.objectContaining({ id: 'remote-tag' }),
]),
'remote',
jasmine.any(Object),
);
// All local ops from conflicts get rejected in one batch
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith([
'local-task',
'local-project',
'local-tag',
]);
// Project: local wins - remote op rejected separately
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-project']);
// Notification should show mixed results
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: {
localWins: 1,
remoteWins: 2,
},
}),
);
});
});
describe('return value (localWinOpsCreated)', () => {
// Helper to create a mock local-win update operation
const createMockLocalWinOp = (entityId: string): Operation => ({
id: `lww-update-${entityId}`,
clientId: 'client-a',
actionType: '[TASK] LWW Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId,
payload: { title: 'Local Win State' },
vectorClock: { clientA: 2, clientB: 1 },
timestamp: Date.now(),
schemaVersion: 1,
});
it('should return { localWinOpsCreated: 0 } when no conflicts', async () => {
const result = await service.autoResolveConflictsLWW([]);
// Early-exit path: no validation runs.
expect(result).toEqual({ localWinOpsCreated: 0 });
});
it('should return { localWinOpsCreated: 0 } when only non-conflicting ops', async () => {
const now = Date.now();
const nonConflicting = [
createOpWithTimestamp('nc-1', 'client-b', now, OpType.Update, 'task-99'),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: nonConflicting,
});
const result = await service.autoResolveConflictsLWW([], nonConflicting);
expect(result).toEqual({ localWinOpsCreated: 0 });
});
it('should return { localWinOpsCreated: 0 } when remote wins all conflicts', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
const result = await service.autoResolveConflictsLWW(conflicts);
expect(result).toEqual({ localWinOpsCreated: 0 });
});
it('should return { localWinOpsCreated: 1 } when local wins one conflict', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
];
// Spy on the private method to return a mock local-win op
spyOn<any>(service, '_createLocalWinUpdateOp').and.returnValue(
Promise.resolve(createMockLocalWinOp('task-1')),
);
const result = await service.autoResolveConflictsLWW(conflicts);
expect(result).toEqual({ localWinOpsCreated: 1 });
});
it('should return correct count when multiple local wins', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
createConflict(
'task-2',
[createOpWithTimestamp('local-2', 'client-a', now, OpType.Update, 'task-2')],
[
createOpWithTimestamp(
'remote-2',
'client-b',
now - 1000,
OpType.Update,
'task-2',
),
],
),
];
// Spy on the private method to return mock local-win ops
spyOn<any>(service, '_createLocalWinUpdateOp').and.callFake(
(conflict: EntityConflict) =>
Promise.resolve(createMockLocalWinOp(conflict.entityId)),
);
const result = await service.autoResolveConflictsLWW(conflicts);
expect(result).toEqual({ localWinOpsCreated: 2 });
});
it('should return correct count for mixed local/remote wins', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
// Remote wins this one
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
// Local wins this one
createConflict(
'task-2',
[createOpWithTimestamp('local-2', 'client-a', now, OpType.Update, 'task-2')],
[
createOpWithTimestamp(
'remote-2',
'client-b',
now - 1000,
OpType.Update,
'task-2',
),
],
),
// Remote wins this one
createConflict(
'task-3',
[
createOpWithTimestamp(
'local-3',
'client-a',
now - 500,
OpType.Update,
'task-3',
),
],
[createOpWithTimestamp('remote-3', 'client-b', now, OpType.Update, 'task-3')],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflicts[0].remoteOps[0], conflicts[2].remoteOps[0]],
});
// Spy on the private method to return mock local-win op (only called for task-2)
spyOn<any>(service, '_createLocalWinUpdateOp').and.callFake(
(conflict: EntityConflict) =>
Promise.resolve(createMockLocalWinOp(conflict.entityId)),
);
const result = await service.autoResolveConflictsLWW(conflicts);
// Only 1 local win out of 3 conflicts
expect(result).toEqual({ localWinOpsCreated: 1 });
});
it('should return 0 when local wins but entity not found', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
];
// Spy on the private method to return undefined (entity not found)
spyOn<any>(service, '_createLocalWinUpdateOp').and.returnValue(
Promise.resolve(undefined),
);
const result = await service.autoResolveConflictsLWW(conflicts);
// No op created because entity not found
expect(result).toEqual({ localWinOpsCreated: 0 });
});
});
describe('vector clock update', () => {
it('should use appendWithVectorClockUpdate for local-win ops to ensure vector clock is updated atomically', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now)],
[createOpWithTimestamp('remote-1', 'client-b', now - 1000)],
),
];
// Mock the private method to return a local-win op
const mockLocalWinOp: Operation = {
id: 'lww-update-task-1',
clientId: 'client-a',
actionType: '[TASK] LWW Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local Win State' },
vectorClock: { clientA: 2, clientB: 1 },
timestamp: now,
schemaVersion: 1,
};
spyOn<any>(service, '_createLocalWinUpdateOp').and.returnValue(
Promise.resolve(mockLocalWinOp),
);
await service.autoResolveConflictsLWW(conflicts);
// Verify appendWithVectorClockUpdate is called (not plain append)
// This ensures the vector clock store is updated atomically with the operation
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
jasmine.objectContaining({
id: 'lww-update-task-1',
actionType: '[TASK] LWW Update' as ActionType,
}),
'local',
);
});
it('should call mergeRemoteOpClocks after applying remote ops (remote wins case)', async () => {
// REGRESSION TEST: Bug where remote ops applied via conflict resolution
// didn't have their clocks merged into the local clock store.
// Without clock merge, subsequent local ops would have clocks that are
// CONCURRENT with the applied remote ops instead of GREATER_THAN.
const now = Date.now();
const remoteOp = createOpWithTimestamp('remote-1', 'client-b', now);
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[remoteOp],
),
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [remoteOp],
});
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
await service.autoResolveConflictsLWW(conflicts);
// CRITICAL: mergeRemoteOpClocks must be called with applied remote ops
expect(mockOpLogStore.mergeRemoteOpClocks).toHaveBeenCalledWith([remoteOp]);
});
it('should process deferred actions after merging remote clocks when caller holds lock', async () => {
const now = Date.now();
const remoteOp = createOpWithTimestamp('remote-1', 'client-b', now);
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[remoteOp],
),
];
const callOrder: string[] = [];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOperationApplier.applyOperations.and.callFake(async () => {
callOrder.push('applyOperations');
return { appliedOps: [remoteOp] };
});
mockOpLogStore.markApplied.and.callFake(async () => {
callOrder.push('markApplied');
});
mockOpLogStore.mergeRemoteOpClocks.and.callFake(async () => {
callOrder.push('mergeRemoteOpClocks');
});
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationLogEffects.processDeferredActions.and.callFake(async () => {
callOrder.push('processDeferredActions');
});
await service.autoResolveConflictsLWW(conflicts, [], {
callerHoldsOperationLogLock: true,
});
expect(mockOperationApplier.applyOperations).toHaveBeenCalledWith([remoteOp], {
skipDeferredLocalActions: true,
});
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalledWith({
callerHoldsOperationLogLock: true,
});
expect(callOrder).toEqual([
'applyOperations',
'markApplied',
'mergeRemoteOpClocks',
'processDeferredActions',
]);
});
it('should call mergeRemoteOpClocks for non-conflicting ops piggybacked through conflict resolution', async () => {
const now = Date.now();
const conflictRemoteOp = createOpWithTimestamp('remote-1', 'client-b', now);
const nonConflictingOp = createOpWithTimestamp(
'nc-1',
'client-c',
now,
OpType.Update,
'other-task',
);
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[conflictRemoteOp],
),
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflictRemoteOp, nonConflictingOp],
});
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
await service.autoResolveConflictsLWW(conflicts, [nonConflictingOp]);
// Both conflict-winning remote ops AND non-conflicting ops should be merged
expect(mockOpLogStore.mergeRemoteOpClocks).toHaveBeenCalledWith([
conflictRemoteOp,
nonConflictingOp,
]);
});
});
// =========================================================================
// Issue #6343: Atomic duplicate skipping (replaces issue #6213 retry logic)
// =========================================================================
describe('atomic duplicate skipping (issue #6343)', () => {
it('should skip duplicates silently during conflict resolution', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
// Simulate: remote op already exists (skipped as duplicate)
mockOpLogStore.appendBatchSkipDuplicates.and.returnValue(
Promise.resolve({ seqs: [], writtenOps: [], skippedCount: 1 }),
);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
// Should complete successfully - no retry needed
await service.autoResolveConflictsLWW(conflicts);
// Should call appendBatchSkipDuplicates exactly once per batch (no retry)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalled();
});
it('should propagate non-duplicate errors', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-1', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-1', 'client-b', now)],
),
];
mockOpLogStore.appendBatchSkipDuplicates.and.rejectWith(
new Error('Some other database error'),
);
await expectAsync(
service.autoResolveConflictsLWW(conflicts),
).toBeRejectedWithError(/Some other database error/);
});
});
});
describe('_deepEqual', () => {
// Access private method for testing
const deepEqual = (a: unknown, b: unknown): boolean =>
(service as any)._deepEqual(a, b);
it('should return true for identical primitives', () => {
expect(deepEqual(1, 1)).toBe(true);
expect(deepEqual('hello', 'hello')).toBe(true);
expect(deepEqual(true, true)).toBe(true);
expect(deepEqual(null, null)).toBe(true);
});
it('should return false for different primitives', () => {
expect(deepEqual(1, 2)).toBe(false);
expect(deepEqual('hello', 'world')).toBe(false);
expect(deepEqual(true, false)).toBe(false);
});
it('should return true for identical objects', () => {
expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
expect(
deepEqual({ nested: { value: 'test' } }, { nested: { value: 'test' } }),
).toBe(true);
});
it('should return true for identical arrays', () => {
expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(deepEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
});
describe('circular reference protection', () => {
it('should return false for objects with circular references', () => {
const warnSpy = spyOn(console, 'warn');
const obj1: Record<string, unknown> = { a: 1 };
obj1['self'] = obj1; // Create circular reference
const obj2: Record<string, unknown> = { a: 1 };
obj2['self'] = obj2; // Create circular reference
const result = deepEqual(obj1, obj2);
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
const warnCalls = warnSpy.calls.allArgs();
// OpLog.warn calls console.warn with prefix '[ol]' as first arg, message as second
const hasCircularWarning = warnCalls.some((args) =>
args.some(
(arg) => typeof arg === 'string' && arg.includes('circular reference'),
),
);
expect(hasCircularWarning).toBe(true);
});
it('should return false for arrays with circular references', () => {
const warnSpy = spyOn(console, 'warn');
const arr1: unknown[] = [1, 2];
arr1.push(arr1); // Create circular reference
const arr2: unknown[] = [1, 2];
arr2.push(arr2); // Create circular reference
const result = deepEqual(arr1, arr2);
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('depth limit protection', () => {
it('should return false for deeply nested objects exceeding max depth', () => {
const warnSpy = spyOn(console, 'warn');
// Create object with depth > 50
let obj1: Record<string, unknown> = { value: 'deep' };
let obj2: Record<string, unknown> = { value: 'deep' };
for (let i = 0; i < 60; i++) {
obj1 = { nested: obj1 };
obj2 = { nested: obj2 };
}
const result = deepEqual(obj1, obj2);
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
const warnCalls = warnSpy.calls.allArgs();
// OpLog.warn calls console.warn with prefix '[ol]' as first arg, message as second
const hasDepthWarning = warnCalls.some((args) =>
args.some(
(arg) => typeof arg === 'string' && arg.includes('exceeded max depth'),
),
);
expect(hasDepthWarning).toBe(true);
});
});
});
describe('_suggestResolution', () => {
// Access private method for testing
const callSuggestResolution = (
localOps: Operation[],
remoteOps: Operation[],
): 'local' | 'remote' | 'manual' => {
return (service as any)._suggestResolution(localOps, remoteOps);
};
const createOp = (partial: Partial<Operation>): Operation => ({
id: 'op-1',
actionType: '[Test] Action' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'entity-1',
payload: {},
clientId: 'client-1',
vectorClock: { client1: 1 },
timestamp: Date.now(),
schemaVersion: 1,
...partial,
});
it('should suggest remote when local ops are empty', () => {
const remoteOps = [createOp({ id: 'remote-1' })];
expect(callSuggestResolution([], remoteOps)).toBe('remote');
});
it('should suggest local when remote ops are empty', () => {
const localOps = [createOp({ id: 'local-1' })];
expect(callSuggestResolution(localOps, [])).toBe('local');
});
it('should suggest newer side when timestamps differ by more than 1 hour', () => {
const now = Date.now();
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const twoHoursAgo = now - TWO_HOURS_MS;
const localOps = [createOp({ id: 'local-1', timestamp: now })];
const remoteOps = [createOp({ id: 'remote-1', timestamp: twoHoursAgo })];
// Local is newer
expect(callSuggestResolution(localOps, remoteOps)).toBe('local');
// Flip: remote is newer
const localOpsOld = [createOp({ id: 'local-1', timestamp: twoHoursAgo })];
const remoteOpsNew = [createOp({ id: 'remote-1', timestamp: now })];
expect(callSuggestResolution(localOpsOld, remoteOpsNew)).toBe('remote');
});
it('should prefer update over delete (local delete, remote update)', () => {
const now = Date.now();
const localOps = [
createOp({ id: 'local-1', opType: OpType.Delete, timestamp: now }),
];
const remoteOps = [
createOp({ id: 'remote-1', opType: OpType.Update, timestamp: now }),
];
expect(callSuggestResolution(localOps, remoteOps)).toBe('remote');
});
it('should prefer update over delete (remote delete, local update)', () => {
const now = Date.now();
const localOps = [
createOp({ id: 'local-1', opType: OpType.Update, timestamp: now }),
];
const remoteOps = [
createOp({ id: 'remote-1', opType: OpType.Delete, timestamp: now }),
];
expect(callSuggestResolution(localOps, remoteOps)).toBe('local');
});
it('should prefer create over update', () => {
const now = Date.now();
const localOps = [
createOp({ id: 'local-1', opType: OpType.Create, timestamp: now }),
];
const remoteOps = [
createOp({ id: 'remote-1', opType: OpType.Update, timestamp: now }),
];
expect(callSuggestResolution(localOps, remoteOps)).toBe('local');
// Flip
const localOps2 = [
createOp({ id: 'local-1', opType: OpType.Update, timestamp: now }),
];
const remoteOps2 = [
createOp({ id: 'remote-1', opType: OpType.Create, timestamp: now }),
];
expect(callSuggestResolution(localOps2, remoteOps2)).toBe('remote');
});
it('should return manual for close timestamps with same op types', () => {
const now = Date.now();
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const fiveMinutesAgo = now - FIVE_MINUTES_MS;
const localOps = [
createOp({ id: 'local-1', opType: OpType.Update, timestamp: now }),
];
const remoteOps = [
createOp({ id: 'remote-1', opType: OpType.Update, timestamp: fiveMinutesAgo }),
];
expect(callSuggestResolution(localOps, remoteOps)).toBe('manual');
});
it('should auto-resolve when both have delete ops (outcome is identical)', () => {
const now = Date.now();
const localOps = [
createOp({ id: 'local-1', opType: OpType.Delete, timestamp: now }),
];
const remoteOps = [
createOp({ id: 'remote-1', opType: OpType.Delete, timestamp: now }),
];
// Both want to delete - auto-resolve to local (could be either, outcome is same)
expect(callSuggestResolution(localOps, remoteOps)).toBe('local');
});
});
// =========================================================================
// Clock skew edge cases
// =========================================================================
// These tests verify LWW conflict resolution handles edge cases where
// clients have significant clock differences.
describe('clock skew edge cases', () => {
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const createOpWithTimestamp = (
id: string,
clientId: string,
timestamp: number,
opType: OpType = OpType.Update,
entityId: string = 'task-1',
): Operation => ({
id,
actionType: '[Task] Update Task' as ActionType,
opType,
entityType: 'TASK',
entityId,
payload: { source: clientId, timestamp },
clientId,
timestamp,
vectorClock: { [clientId]: 1 },
schemaVersion: 1,
});
it('should handle timestamps in far future (client clock ahead)', async () => {
const now = Date.now();
const futureTime = now + ONE_YEAR_MS; // 1 year in future
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', futureTime)],
suggestedResolution: 'remote',
},
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflicts[0].remoteOps[0]],
});
// Remote wins because its timestamp is newer (even if unrealistic)
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-1' })]),
'remote',
jasmine.any(Object),
);
});
it('should handle timestamps in far past (client clock behind)', async () => {
const now = Date.now();
const pastTime = now - ONE_YEAR_MS; // 1 year in past
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', pastTime)],
suggestedResolution: 'local',
},
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
// Local wins because its timestamp is newer
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
});
it('should handle zero timestamps gracefully', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', 0)],
suggestedResolution: 'local',
},
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
// Local wins (0 is earlier than now)
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
});
it('should handle negative timestamps gracefully (system clock errors)', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', -1000)],
suggestedResolution: 'local',
},
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
// Should not throw, local wins
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
});
it('should use client ID as stable tie-breaker for identical timestamps', async () => {
const now = Date.now();
// Both have exactly the same timestamp
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
// client-a < client-b alphabetically, but we test that remote wins on tie
localOps: [createOpWithTimestamp('local-1', 'client-a', now)],
remoteOps: [createOpWithTimestamp('remote-1', 'client-b', now)],
suggestedResolution: 'remote', // Remote wins on tie
},
];
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [conflicts[0].remoteOps[0]],
});
await service.autoResolveConflictsLWW(conflicts);
// Remote should win on tie
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-1' })]),
'remote',
jasmine.any(Object),
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
});
});
// =========================================================================
// Archive conflict resolution (Bug B prevention)
// =========================================================================
describe('archive conflict resolution', () => {
const createOpWithTimestamp = (
id: string,
clientId: string,
timestamp: number,
opType: OpType = OpType.Update,
entityId: string = 'task-1',
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType,
entityType: 'TASK',
entityId,
payload: { source: clientId, timestamp },
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
const createArchiveOp = (
id: string,
clientId: string,
timestamp: number,
entityId: string = 'task-1',
entityIds: string[] = ['task-1'],
): Operation => ({
id,
clientId,
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
opType: OpType.Update,
entityType: 'TASK',
entityId,
entityIds,
payload: {
actionPayload: {
tasks: entityIds.map((eid) => ({ id: eid, title: `Task ${eid}` })),
},
entityChanges: [],
},
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
const createConflict = (
entityId: string,
localOps: Operation[],
remoteOps: Operation[],
): EntityConflict => ({
entityType: 'TASK',
entityId,
localOps,
remoteOps,
suggestedResolution: 'manual',
});
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
});
it('should resolve remote moveToArchive as winner over local UPDATE (regardless of timestamps)', async () => {
const now = Date.now();
// Local UPDATE has NEWER timestamp, but remote archive should still win
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-upd', 'client-a', now)],
[createArchiveOp('remote-archive', 'client-b', now - 5000)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote archive should win — applied as remote op
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-archive' })]),
'remote',
jasmine.any(Object),
);
// Local ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-upd']);
});
it('should resolve local moveToArchive as winner over remote UPDATE (regardless of timestamps)', async () => {
const now = Date.now();
// Remote UPDATE has NEWER timestamp, but local archive should still win
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now - 5000)],
[createOpWithTimestamp('remote-upd', 'client-b', now)],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Local archive wins — remote op should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
// Local archive op should be rejected (will be replaced by new archive op)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-archive']);
// New archive op should be appended
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
it('should create replacement archive op with merged clock when local archive wins', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now - 5000)],
[createOpWithTimestamp('remote-upd', 'client-b', now)],
),
];
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
// Merged clock should include entries from both sides and be incremented
expect(appendedOp.vectorClock['client-a']).toBeGreaterThanOrEqual(1);
expect(appendedOp.vectorClock['client-b']).toBeGreaterThanOrEqual(1);
expect(appendedOp.vectorClock[TEST_CLIENT_ID]).toBeGreaterThanOrEqual(1);
});
it('should preserve original payload and entityIds in replacement archive op', async () => {
const now = Date.now();
const archiveOp = createArchiveOp('local-archive', 'client-a', now, 'task-1', [
'task-1',
'task-2',
'task-3',
]);
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[archiveOp],
[createOpWithTimestamp('remote-upd', 'client-b', now + 5000)],
),
];
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
expect(appendedOp.payload).toEqual(archiveOp.payload);
expect(appendedOp.entityIds).toEqual(['task-1', 'task-2', 'task-3']);
expect(appendedOp.entityId).toBe('task-1');
expect(appendedOp.timestamp).toBe(now);
});
it('should still use normal LWW timestamp comparison for non-archive conflicts', async () => {
const now = Date.now();
// Normal UPDATE vs UPDATE conflict — no archive involved
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createOpWithTimestamp('local-upd', 'client-a', now)],
[createOpWithTimestamp('remote-upd', 'client-b', now - 1000)],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Local wins by timestamp — normal LWW behavior
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-upd']);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
});
it('should not create local-win op when clientId is unavailable', async () => {
const now = Date.now();
mockClientIdProvider.loadClientId.and.returnValue(Promise.resolve(null));
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now)],
[createOpWithTimestamp('remote-upd', 'client-b', now + 5000)],
),
];
const result = await service.autoResolveConflictsLWW(conflicts);
// No local-win op should be created (clientId unavailable)
expect(mockOpLogStore.appendWithVectorClockUpdate).not.toHaveBeenCalled();
expect(result.localWinOpsCreated).toBe(0);
});
it('should handle local ops with both moveToArchive and regular update in same conflict', async () => {
const now = Date.now();
// Local side has a regular update AND a moveToArchive for the same entity
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[
createOpWithTimestamp('local-upd', 'client-a', now - 10000),
createArchiveOp('local-archive', 'client-a', now),
],
[createOpWithTimestamp('remote-upd', 'client-b', now + 5000)],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Archive should still win — both local ops rejected, archive re-created
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith([
'local-upd',
'local-archive',
]);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
it('should use normal LWW for archive vs DELETE conflict (archive is not special against DELETE)', async () => {
const now = Date.now();
// Local archive, remote DELETE — archive special-casing only applies against
// field-level UPDATEs, not DELETEs. DELETE conflicts use normal LWW.
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now)],
[
{
...createOpWithTimestamp('remote-del', 'client-b', now - 1000),
opType: OpType.Delete,
},
],
),
];
await service.autoResolveConflictsLWW(conflicts);
// Archive should still win — it has moveToArchive, so archive-wins logic kicks in
// regardless of remote op type
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-del']);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
});
it('should resolve as remote when both sides have moveToArchive', async () => {
const now = Date.now();
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now)],
[createArchiveOp('remote-archive', 'client-b', now - 1000)],
),
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: conflicts[0].remoteOps,
});
await service.autoResolveConflictsLWW(conflicts);
// Remote archive wins (both have archive → remote preferred)
expect(mockOpLogStore.appendBatchSkipDuplicates).toHaveBeenCalledWith(
jasmine.arrayContaining([jasmine.objectContaining({ id: 'remote-archive' })]),
'remote',
jasmine.any(Object),
);
});
it('should prevent entity resurrection when multiple conflicts exist for same entity with local archive', async () => {
const now = Date.now();
// Two conflicts for the same entity (task-1):
// - Conflict 1: local archive vs remote field update
// - Conflict 2: local field update vs remote field update (no archive in THIS conflict)
// Pre-scan detects that task-1 has a local archive across ALL conflicts.
// Conflict 2 must NOT apply remote ops (which would resurrect the archived entity).
const conflicts: EntityConflict[] = [
createConflict(
'task-1',
[createArchiveOp('local-archive', 'client-a', now)],
[createOpWithTimestamp('remote-status-update', 'client-b', now + 5000)],
),
createConflict(
'task-1',
[createOpWithTimestamp('local-title-update', 'client-a', now - 1000)],
[createOpWithTimestamp('remote-notes-update', 'client-b', now + 5000)],
),
];
const result = await service.autoResolveConflictsLWW(conflicts);
// Archive-win op should be created (from Conflict 1)
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
jasmine.objectContaining({
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
}),
'local',
);
// No remote ops should be applied to the store — both conflicts resolve
// as local-wins, so allOpsToApply is empty and applyOperations is never called.
// This prevents remote-notes-update from resurrecting the entity via addOne().
expect(mockOperationApplier.applyOperations).not.toHaveBeenCalled();
// Both sets of remote ops should be rejected (stored for history but not applied)
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(
jasmine.arrayContaining(['remote-status-update', 'remote-notes-update']),
);
});
});
describe('no client-side vector clock pruning in conflict resolution', () => {
const createOpWithLargeClock = (
id: string,
clientId: string,
timestamp: number,
vectorClock: VectorClock,
entityId: string = 'task-1',
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId,
payload: { source: clientId },
vectorClock,
timestamp,
schemaVersion: 1,
});
const createLargeClock = (
prefix: string,
count: number,
valueStart: number = 1,
): VectorClock => {
const clock: VectorClock = {};
for (let i = 1; i <= count; i++) {
clock[`${prefix}-${i}`] = valueStart + i - 1;
}
return clock;
};
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake((op: Operation) => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake((op: Operation) =>
Promise.resolve(1),
);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
});
it('should NOT prune local-win update op clock (server handles pruning)', async () => {
const now = Date.now();
const localClock = createLargeClock('local', 16, 1);
const remoteClock = createLargeClock('remote', 16, 10);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithLargeClock('local-1', 'client-a', now, localClock)],
remoteOps: [
createOpWithLargeClock('remote-1', 'client-b', now - 1000, remoteClock),
],
suggestedResolution: 'manual',
},
];
mockStore.select.and.returnValue(of({ id: 'task-1', title: 'Test Task' }));
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
// All keys preserved — no client-side pruning (server handles it)
expect(Object.keys(appendedOp.vectorClock).length).toBeGreaterThan(
MAX_VECTOR_CLOCK_SIZE,
);
expect(appendedOp.vectorClock[TEST_CLIENT_ID]).toBeDefined();
});
it('should NOT prune archive-win op clock (server handles pruning)', async () => {
const now = Date.now();
const archiveClock = createLargeClock('archive', 16, 1);
const remoteClock = createLargeClock('remote', 16, 10);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [
{
id: 'local-archive',
clientId: 'client-a',
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
entityIds: ['task-1'],
payload: {
actionPayload: { tasks: [{ id: 'task-1' }] },
entityChanges: [],
},
vectorClock: archiveClock,
timestamp: now,
schemaVersion: 1,
},
],
remoteOps: [
createOpWithLargeClock('remote-upd', 'client-b', now + 5000, remoteClock),
],
suggestedResolution: 'manual',
},
];
await service.autoResolveConflictsLWW(conflicts);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
// All keys preserved — no client-side pruning (server handles it)
expect(Object.keys(appendedOp.vectorClock).length).toBeGreaterThan(
MAX_VECTOR_CLOCK_SIZE,
);
expect(appendedOp.vectorClock[TEST_CLIENT_ID]).toBeDefined();
});
it('should preserve all client IDs in unpruned local-win op clock', async () => {
const protectedId = 'protected-sync-import-client';
const now = Date.now();
const localClock: VectorClock = { [protectedId]: 1 };
for (let i = 1; i <= 5; i++) {
localClock[`high-local-${i}`] = i * 100;
}
const remoteClock = createLargeClock('remote', 5, 50);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [createOpWithLargeClock('local-1', 'client-a', now, localClock)],
remoteOps: [
createOpWithLargeClock('remote-1', 'client-b', now - 1000, remoteClock),
],
suggestedResolution: 'manual',
},
];
mockStore.select.and.returnValue(of({ id: 'task-1', title: 'Test Task' }));
await service.autoResolveConflictsLWW(conflicts);
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
.args[0] as Operation;
// No client-side pruning — all keys preserved including protected client
expect(appendedOp.vectorClock[protectedId]).toBeDefined();
expect(appendedOp.vectorClock[TEST_CLIENT_ID]).toBeDefined();
});
});
describe('archive-wins rule', () => {
it('should resolve local moveToArchive winning over remote UPDATE with later timestamp', async () => {
const localArchiveOp: Operation = {
id: 'local-archive-1',
clientId: TEST_CLIENT_ID,
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { id: 'task-1', title: 'Archived Task' } },
vectorClock: { [TEST_CLIENT_ID]: 1 },
timestamp: 1000,
schemaVersion: 1,
};
const remoteUpdateOp: Operation = {
id: 'remote-update-1',
clientId: 'remoteClient',
actionType: 'test-update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { source: 'remoteClient' },
vectorClock: { remoteClient: 1 },
timestamp: 2000, // Later timestamp — would win under normal LWW
schemaVersion: 1,
};
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localArchiveOp],
remoteOps: [remoteUpdateOp],
suggestedResolution: 'manual',
},
];
mockStore.select.and.returnValue(of(undefined));
const result = await service.autoResolveConflictsLWW(conflicts);
// Local archive wins — a new op should be created
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
});
it('should resolve remote moveToArchive winning over local UPDATE with later timestamp', async () => {
const localUpdateOp: Operation = {
id: 'local-update-1',
clientId: TEST_CLIENT_ID,
actionType: 'test-update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { source: TEST_CLIENT_ID },
vectorClock: { [TEST_CLIENT_ID]: 1 },
timestamp: 2000, // Later timestamp — would win under normal LWW
schemaVersion: 1,
};
const remoteArchiveOp: Operation = {
id: 'remote-archive-1',
clientId: 'remoteClient',
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { id: 'task-1', title: 'Archived Task' } },
vectorClock: { remoteClient: 1 },
timestamp: 1000,
schemaVersion: 1,
};
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localUpdateOp],
remoteOps: [remoteArchiveOp],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [remoteArchiveOp],
});
const result = await service.autoResolveConflictsLWW(conflicts);
// Remote archive wins — no local-win op created
expect(result.localWinOpsCreated).toBe(0);
// Remote archive op should be applied
expect(mockOperationApplier.applyOperations).toHaveBeenCalled();
// Local op should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalled();
});
it('should resolve local moveToArchive winning over remote DELETE with later timestamp', async () => {
const localArchiveOp: Operation = {
id: 'local-archive-1',
clientId: TEST_CLIENT_ID,
actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { id: 'task-1', title: 'Archived Task' } },
vectorClock: { [TEST_CLIENT_ID]: 1 },
timestamp: 1000,
schemaVersion: 1,
};
const remoteDeleteOp: Operation = {
id: 'remote-delete-1',
clientId: 'remoteClient',
actionType: 'test-delete' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: { source: 'remoteClient' },
vectorClock: { remoteClient: 1 },
timestamp: 2000, // Later timestamp — would win under normal LWW
schemaVersion: 1,
};
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localArchiveOp],
remoteOps: [remoteDeleteOp],
suggestedResolution: 'manual',
},
];
mockStore.select.and.returnValue(of(undefined));
const result = await service.autoResolveConflictsLWW(conflicts);
// Local archive wins over remote DELETE
expect(result.localWinOpsCreated).toBe(1);
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalled();
});
});
describe('checkOpForConflicts', () => {
const buildCtx = (overrides: {
localPendingOpsByEntity?: Map<string, Operation[]>;
appliedFrontierByEntity?: Map<string, VectorClock>;
snapshotVectorClock?: VectorClock;
snapshotEntityKeys?: Set<string>;
hasNoSnapshotClock?: boolean;
}): {
localPendingOpsByEntity: Map<string, Operation[]>;
appliedFrontierByEntity: Map<string, VectorClock>;
snapshotVectorClock: VectorClock | undefined;
snapshotEntityKeys: Set<string>;
hasNoSnapshotClock: boolean;
} => ({
localPendingOpsByEntity: overrides.localPendingOpsByEntity ?? new Map(),
appliedFrontierByEntity: overrides.appliedFrontierByEntity ?? new Map(),
snapshotVectorClock: overrides.snapshotVectorClock,
snapshotEntityKeys: overrides.snapshotEntityKeys ?? new Set(),
hasNoSnapshotClock: overrides.hasNoSnapshotClock ?? true,
});
it('should mark CONCURRENT remote op as superseded when entity no longer in state', async () => {
// Scenario: Client A archived a task (already synced), Client B sends a
// concurrent update. Client A has no pending ops, but local frontier
// shows CONCURRENT clocks. Entity is gone from NgRx store.
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientB'),
entityId: 'task-1',
vectorClock: { clientB: 1 },
};
// Local frontier has {clientA: 1} from an already-synced archive op,
// producing CONCURRENT with remote's {clientB: 1}
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
// Entity no longer in state (archived/deleted)
mockStore.select.and.returnValue(of(undefined));
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ appliedFrontierByEntity }),
);
expect(result.isSupersededOrDuplicate).toBe(true);
expect(result.conflict).toBeNull();
});
it('should NOT mark CONCURRENT remote op as superseded when entity still in state', async () => {
// Same CONCURRENT scenario, but entity still exists (no archive/delete)
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientB'),
entityId: 'task-1',
vectorClock: { clientB: 1 },
};
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
// Entity still exists in state
mockStore.select.and.returnValue(of({ id: 'task-1', title: 'Still here' }));
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ appliedFrontierByEntity }),
);
expect(result.isSupersededOrDuplicate).toBe(false);
expect(result.conflict).toBeNull();
});
it('should mark CONCURRENT remote op as superseded when entity state is null', async () => {
// Some selectors may return null instead of undefined for missing entities
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientB'),
entityId: 'task-1',
vectorClock: { clientB: 1 },
};
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
mockStore.select.and.returnValue(of(null));
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ appliedFrontierByEntity }),
);
expect(result.isSupersededOrDuplicate).toBe(true);
expect(result.conflict).toBeNull();
});
it('should NOT check entity state for LESS_THAN remote op with no pending ops', async () => {
// LESS_THAN means remote is newer — should apply normally without entity check
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientA'),
entityId: 'task-1',
vectorClock: { clientA: 2 },
};
// Local frontier has {clientA: 1}, remote has {clientA: 2} = LESS_THAN
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ appliedFrontierByEntity }),
);
expect(result.isSupersededOrDuplicate).toBe(false);
expect(result.conflict).toBeNull();
// Should NOT have called store.select for entity state check
expect(mockStore.select).not.toHaveBeenCalled();
});
it('should detect CONCURRENT conflict when pending local ops exist', async () => {
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientB'),
entityId: 'task-1',
vectorClock: { clientB: 1 },
};
const localOp: Operation = {
...createMockOp('local-1', 'clientA'),
entityId: 'task-1',
vectorClock: { clientA: 1 },
};
const localPendingOpsByEntity = new Map<string, Operation[]>();
localPendingOpsByEntity.set('TASK:task-1', [localOp]);
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ localPendingOpsByEntity, appliedFrontierByEntity }),
);
expect(result.isSupersededOrDuplicate).toBe(false);
expect(result.conflict).not.toBeNull();
expect(result.conflict!.entityId).toBe('task-1');
// Should NOT have called store.select — pending ops exist, so normal conflict path
expect(mockStore.select).not.toHaveBeenCalled();
});
it('should use entityId fallback when entityIds is an empty array', async () => {
// Regression test: entityIds: [] is truthy in JS, so the old || fallback
// would use the empty array instead of falling back to entityId.
const remoteOp: Operation = {
...createMockOp('remote-1', 'clientB'),
entityId: 'task-1',
entityIds: [],
vectorClock: { clientB: 1 },
};
const localOp: Operation = {
...createMockOp('local-1', 'clientA'),
entityId: 'task-1',
vectorClock: { clientA: 1 },
};
const localPendingOpsByEntity = new Map<string, Operation[]>();
localPendingOpsByEntity.set('TASK:task-1', [localOp]);
const appliedFrontierByEntity = new Map<string, VectorClock>();
appliedFrontierByEntity.set('TASK:task-1', { clientA: 1 });
const result = await service.checkOpForConflicts(
remoteOp,
buildCtx({ localPendingOpsByEntity, appliedFrontierByEntity }),
);
// With the fix, entityId 'task-1' is resolved and a conflict is detected.
// Without the fix, entityIds: [] would be used as-is, skipping the entity entirely.
expect(result.conflict).not.toBeNull();
expect(result.conflict!.entityId).toBe('task-1');
});
});
describe('_convertToLWWUpdatesIfNeeded', () => {
const createOpWithTimestamp = (
id: string,
clientId: string,
timestamp: number,
opType: OpType = OpType.Update,
entityId: string = 'task-1',
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType,
entityType: 'TASK',
entityId,
payload: { source: clientId },
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
const createConflict = (
entityId: string,
localOps: Operation[],
remoteOps: Operation[],
): EntityConflict => ({
entityType: 'TASK',
entityId,
localOps,
remoteOps,
suggestedResolution: 'manual',
});
it('should return remote ops unchanged when no local DELETE exists', () => {
const remoteOp = {
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-upd', 'client-a', Date.now() - 1000),
opType: OpType.Update,
},
],
[remoteOp],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result).toEqual([remoteOp]);
});
it('should convert remote UPDATE to LWW Update with merged entity when local DELETE has flat payload', () => {
const fullEntity = {
id: 'task-1',
title: 'Original Title',
projectId: 'proj-1',
tagIds: ['tag-1'],
dueDay: '2025-01-15',
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: fullEntity },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { title: 'Updated Title' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result.length).toBe(1);
expect(result[0].actionType).toBe('[TASK] LWW Update');
expect(result[0].payload).toEqual({
id: 'task-1',
title: 'Updated Title',
projectId: 'proj-1',
tagIds: ['tag-1'],
dueDay: '2025-01-15',
});
});
it('should convert remote UPDATE to LWW Update with merged entity when local DELETE has MultiEntityPayload', () => {
const fullEntity = {
id: 'task-1',
title: 'Original Title',
projectId: 'proj-1',
tagIds: ['tag-1'],
dueDay: '2025-01-15',
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: {
actionPayload: { task: fullEntity },
entityChanges: [],
},
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: {
actionPayload: {
task: { id: 'task-1', changes: { title: 'Updated Title' } },
},
entityChanges: [],
},
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result.length).toBe(1);
expect(result[0].actionType).toBe('[TASK] LWW Update');
expect(result[0].payload).toEqual({
id: 'task-1',
title: 'Updated Title',
projectId: 'proj-1',
tagIds: ['tag-1'],
dueDay: '2025-01-15',
});
});
it('should preserve all base entity fields when UPDATE only changes one field', () => {
const fullEntity = {
id: 'task-1',
title: 'Original',
notes: 'Some notes',
projectId: 'proj-1',
tagIds: ['tag-1', 'tag-2'],
dueDay: '2025-06-01',
timeEstimate: 3600000,
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: fullEntity },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { title: 'New Title' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result[0].payload.notes).toBe('Some notes');
expect(result[0].payload.projectId).toBe('proj-1');
expect(result[0].payload.tagIds).toEqual(['tag-1', 'tag-2']);
expect(result[0].payload.dueDay).toBe('2025-06-01');
expect(result[0].payload.timeEstimate).toBe(3600000);
expect(result[0].payload.title).toBe('New Title');
});
it('should not convert non-UPDATE remote ops even when local DELETE exists', () => {
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: { id: 'task-1', title: 'Deleted' } },
},
],
[
{
...createOpWithTimestamp('remote-create', 'client-b', Date.now()),
opType: OpType.Create,
payload: { task: { id: 'task-1', title: 'Created' } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result.length).toBe(1);
expect(result[0].opType).toBe(OpType.Create);
expect(result[0].actionType).toBe('test');
});
it('should return remote op unchanged when base entity cannot be extracted', () => {
// Rewriting actionType to LWW Update with an unmerged NgRx UPDATE payload
// would no-op at the consumer (lwwUpdateMetaReducer bails when entityData
// has no top-level id). The fallback now leaves the op alone instead of
// pretending to convert it.
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: {},
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { title: 'Updated' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result[0].actionType).toBe('test');
expect(result[0].payload).toEqual({
task: { id: 'task-1', changes: { title: 'Updated' } },
});
});
it('should handle flat UPDATE payload format (no changes wrapper)', () => {
const fullEntity = {
id: 'task-1',
title: 'Original',
projectId: 'proj-1',
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: fullEntity },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', title: 'Flat Update' } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result[0].payload).toEqual({
id: 'task-1',
title: 'Flat Update',
projectId: 'proj-1',
});
});
it('should fall back when local DELETE is bulk deleteTasks (only taskIds, no entity)', () => {
// deleteTasks action payload: { taskIds: string[] } — no full entity
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { taskIds: ['task-1', 'task-2'] },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { title: 'Updated' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result.length).toBe(1);
// Fallback returns the remote op unchanged — original actionType + payload preserved.
expect(result[0].actionType).toBe('test');
expect(result[0].payload).toEqual({
task: { id: 'task-1', changes: { title: 'Updated' } },
});
});
it('should fall back when local DELETE payload has no recognizable entity structure', () => {
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { someUnrelatedKey: 'value' },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { notes: 'New notes' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
// Fallback returns the remote op unchanged — original actionType + payload preserved.
expect(result[0].actionType).toBe('test');
expect(result[0].payload).toEqual({
task: { id: 'task-1', changes: { notes: 'New notes' } },
});
});
it('should produce merged entity with top-level id when base entity is available', () => {
// This verifies the happy path produces a payload that
// lwwUpdateMetaReducer can consume (requires top-level `id`)
const fullEntity = {
id: 'task-1',
title: 'Original',
projectId: 'proj-1',
};
const conflict = createConflict(
'task-1',
[
{
...createOpWithTimestamp('local-del', 'client-a', Date.now() - 1000),
opType: OpType.Delete,
payload: { task: fullEntity },
},
],
[
{
...createOpWithTimestamp('remote-upd', 'client-b', Date.now()),
opType: OpType.Update,
payload: { task: { id: 'task-1', changes: { title: 'New' } } },
},
],
);
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
// Merged payload has top-level `id` — required by lwwUpdateMetaReducer
expect(result[0].payload.id).toBe('task-1');
expect(typeof result[0].payload.id).toBe('string');
});
});
describe('_extractEntityFromPayload', () => {
it('should extract entity from flat payload using entity key', () => {
const entity = { id: 'task-1', title: 'Test' };
const payload = { task: entity };
const result = (service as any)._extractEntityFromPayload(payload, 'TASK');
expect(result).toEqual(entity);
});
it('should extract entity from MultiEntityPayload format', () => {
const entity = { id: 'task-1', title: 'Test' };
const payload = {
actionPayload: { task: entity },
entityChanges: [],
};
const result = (service as any)._extractEntityFromPayload(payload, 'TASK');
expect(result).toEqual(entity);
});
it('should fall back to payload itself when it has an id property', () => {
const payload = { id: 'task-1', title: 'Direct Entity' };
const result = (service as any)._extractEntityFromPayload(payload, 'TASK');
expect(result).toEqual(payload);
});
it('should return undefined when entity key is not found and payload has no id', () => {
const payload = { unrelatedKey: 'value' };
const result = (service as any)._extractEntityFromPayload(payload, 'TASK');
expect(result).toBeUndefined();
});
it('should return undefined for null payload values under entity key', () => {
const payload = { task: null };
const result = (service as any)._extractEntityFromPayload(payload, 'TASK');
expect(result).toBeUndefined();
});
});
describe('_extractUpdateChanges', () => {
it('should extract changes from NgRx adapter format (id + changes)', () => {
const payload = {
task: { id: 'task-1', changes: { title: 'New', notes: 'Updated' } },
};
const result = (service as any)._extractUpdateChanges(payload, 'TASK');
expect(result).toEqual({ title: 'New', notes: 'Updated' });
});
it('should extract changes from flat format (exclude id)', () => {
const payload = { task: { id: 'task-1', title: 'New', notes: 'Updated' } };
const result = (service as any)._extractUpdateChanges(payload, 'TASK');
expect(result).toEqual({ title: 'New', notes: 'Updated' });
});
it('should handle MultiEntityPayload wrapping NgRx adapter format', () => {
const payload = {
actionPayload: {
task: { id: 'task-1', changes: { title: 'New' } },
},
entityChanges: [],
};
const result = (service as any)._extractUpdateChanges(payload, 'TASK');
expect(result).toEqual({ title: 'New' });
});
it('should handle MultiEntityPayload wrapping flat format', () => {
const payload = {
actionPayload: {
task: { id: 'task-1', title: 'New' },
},
entityChanges: [],
};
const result = (service as any)._extractUpdateChanges(payload, 'TASK');
expect(result).toEqual({ title: 'New' });
});
it('should return empty object when entity key is not in payload', () => {
const payload = { wrongKey: { id: 'task-1', title: 'New' } };
const result = (service as any)._extractUpdateChanges(payload, 'TASK');
expect(result).toEqual({});
});
});
describe('_extractEntityFromDeleteOperation with MultiEntityPayload', () => {
const createOpWithTimestamp = (
id: string,
clientId: string,
timestamp: number,
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: {},
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
it('should extract entity from MultiEntityPayload format in DELETE operation', () => {
const taskEntity = {
id: 'task-1',
title: 'Test Task',
projectId: 'project-1',
};
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [],
remoteOps: [
{
...createOpWithTimestamp('remote-del', 'client-b', Date.now()),
payload: {
actionPayload: { task: taskEntity },
entityChanges: [],
},
},
],
suggestedResolution: 'manual',
};
const result = (service as any)._extractEntityFromDeleteOperation(conflict);
expect(result).toEqual(taskEntity);
});
it('should extract entity from flat payload format in DELETE operation', () => {
const taskEntity = {
id: 'task-1',
title: 'Test Task',
projectId: 'project-1',
};
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
localOps: [],
remoteOps: [
{
...createOpWithTimestamp('remote-del', 'client-b', Date.now()),
payload: { task: taskEntity },
},
],
suggestedResolution: 'manual',
};
const result = (service as any)._extractEntityFromDeleteOperation(conflict);
expect(result).toEqual(taskEntity);
});
});
describe('_adjustForClockCorruption', () => {
/**
* Tests for the clock corruption recovery mechanism.
*
* The method detects potential per-entity clock corruption when:
* - Entity has pending local ops (we made changes)
* - But has no snapshot clock AND empty local frontier
* - This suggests the clock data was lost/corrupted
*
* When corruption is suspected, LESS_THAN and GREATER_THAN are converted
* to CONCURRENT to force conflict resolution (safer than silently skipping).
*/
const adjustForClockCorruption = (
comparison: VectorClockComparison,
entityKey: string,
ctx: {
localOpsForEntity: Operation[];
hasNoSnapshotClock: boolean;
localFrontierIsEmpty: boolean;
},
): VectorClockComparison => {
return (service as any)._adjustForClockCorruption(comparison, entityKey, ctx);
};
describe('when NO corruption suspected (normal case)', () => {
it('should return LESS_THAN unchanged', () => {
const result = adjustForClockCorruption(
VectorClockComparison.LESS_THAN,
'TASK:task-1',
{
localOpsForEntity: [], // No pending ops - no corruption possible
hasNoSnapshotClock: false,
localFrontierIsEmpty: false,
},
);
expect(result).toBe(VectorClockComparison.LESS_THAN);
});
it('should return GREATER_THAN unchanged', () => {
const result = adjustForClockCorruption(
VectorClockComparison.GREATER_THAN,
'TASK:task-1',
{
localOpsForEntity: [], // No pending ops
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.GREATER_THAN);
});
it('should return CONCURRENT unchanged', () => {
const result = adjustForClockCorruption(
VectorClockComparison.CONCURRENT,
'TASK:task-1',
{
localOpsForEntity: [],
hasNoSnapshotClock: false,
localFrontierIsEmpty: false,
},
);
expect(result).toBe(VectorClockComparison.CONCURRENT);
});
it('should return EQUAL unchanged', () => {
const result = adjustForClockCorruption(
VectorClockComparison.EQUAL,
'TASK:task-1',
{
localOpsForEntity: [],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.EQUAL);
});
it('should NOT adjust when entity has pending ops but HAS snapshot clock', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.LESS_THAN,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: false, // Has snapshot clock - no corruption
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.LESS_THAN);
});
it('should NOT adjust when entity has pending ops but HAS local frontier', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.GREATER_THAN,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: true,
localFrontierIsEmpty: false, // Has frontier - no corruption
},
);
expect(result).toBe(VectorClockComparison.GREATER_THAN);
});
});
describe('when clock corruption IS suspected', () => {
/**
* Corruption is suspected when ALL three conditions are met:
* 1. Entity has pending local ops
* 2. No snapshot clock exists
* 3. Local frontier is empty
*/
beforeEach(() => {
// Prevent devError from throwing (it calls alert + confirm → throws if confirm returns true)
if (!jasmine.isSpy(window.alert)) {
spyOn(window, 'alert');
}
if (!jasmine.isSpy(window.confirm)) {
spyOn(window, 'confirm').and.returnValue(false);
} else {
(window.confirm as jasmine.Spy).and.returnValue(false);
}
});
it('should convert LESS_THAN to CONCURRENT', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.LESS_THAN,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.CONCURRENT);
});
it('should convert GREATER_THAN to CONCURRENT', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.GREATER_THAN,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.CONCURRENT);
});
it('should NOT change CONCURRENT (already safest)', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.CONCURRENT,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.CONCURRENT);
});
it('should NOT change EQUAL (duplicates are safe to skip)', () => {
const localOp = createMockOp('op-1', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.EQUAL,
'TASK:task-1',
{
localOpsForEntity: [localOp],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.EQUAL);
});
it('should handle multiple pending ops', () => {
const op1 = createMockOp('op-1', 'client-a');
const op2 = createMockOp('op-2', 'client-a');
const result = adjustForClockCorruption(
VectorClockComparison.LESS_THAN,
'TASK:task-1',
{
localOpsForEntity: [op1, op2],
hasNoSnapshotClock: true,
localFrontierIsEmpty: true,
},
);
expect(result).toBe(VectorClockComparison.CONCURRENT);
});
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BUG CONFIRMATION TEST (Issue #6571)
// ═══════════════════════════════════════════════════════════════════════════
describe('Bug #6571: LWW apply failure does not throw', () => {
const now = Date.now();
const createOpForBug = (
id: string,
clientId: string,
timestamp: number,
): Operation => ({
id,
clientId,
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { source: clientId },
vectorClock: { [clientId]: 1 },
timestamp,
schemaVersion: 1,
});
beforeEach(() => {
mockOpLogStore.hasOp.and.resolveTo(false);
mockOpLogStore.append.and.callFake(() => Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.callFake(() => Promise.resolve(1));
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.markFailed.and.resolveTo(undefined);
});
it('should throw when applyOperations has a failedOp', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
];
mockOperationApplier.applyOperations.and.resolveTo({
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('Apply failed for task-1') },
});
// FIXED: Should throw on apply failure (parity with applyNonConflictingOps)
await expectAsync(service.autoResolveConflictsLWW(conflicts)).toBeRejectedWithError(
'Apply failed for task-1',
);
expect(mockOpLogStore.markFailed).toHaveBeenCalled();
expect(mockSnackService.open).toHaveBeenCalled();
});
// Issue #7700: the deferred-actions flush must run even on the apply-
// failure path. Pre-fix, replayOperationBatch's finally took care of
// this; with the fix, the host explicitly calls
// _processDeferredActionsAfterRemoteApply before the throw. If the
// failure path ever forgets to flush, deferred local actions linger
// in the buffer until the NEXT sync — surfacing as ghost ops with
// stale clocks. This test pins the flush.
it('should process deferred actions before throwing on failedOp (callerHoldsOperationLogLock=true)', async () => {
const localOp = createOpForBug('local-1', 'client-a', now - 1000);
const remoteOp = createOpForBug('remote-1', 'client-b', now);
const conflicts: EntityConflict[] = [
{
entityType: 'TASK',
entityId: 'task-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
];
const callOrder: string[] = [];
mockOperationApplier.applyOperations.and.callFake(async () => {
callOrder.push('applyOperations');
return {
appliedOps: [],
failedOp: { op: remoteOp, error: new Error('Apply failed for task-1') },
};
});
mockOpLogStore.markFailed.and.callFake(async () => {
callOrder.push('markFailed');
});
mockOperationLogEffects.processDeferredActions.and.callFake(async () => {
callOrder.push('processDeferredActions');
});
await expectAsync(
service.autoResolveConflictsLWW(conflicts, [], {
callerHoldsOperationLogLock: true,
}),
).toBeRejectedWithError('Apply failed for task-1');
// Deferred flush must happen BEFORE the throw, and must thread the
// caller-holds-lock flag through so writeOperation skips the inner
// sp_op_log acquisition.
expect(mockOperationLogEffects.processDeferredActions).toHaveBeenCalledWith({
callerHoldsOperationLogLock: true,
});
expect(callOrder).toContain('processDeferredActions');
expect(callOrder.indexOf('processDeferredActions')).toBeGreaterThan(
callOrder.indexOf('applyOperations'),
);
});
});
describe('LWW Update payload always has top-level id (#7330)', () => {
// The lwwUpdateMetaReducer bails with "Entity data has no id" when an LWW
// Update payload lacks a top-level id. createLWWUpdateOp is the choke point
// for two of three LWW Update producers (_createLocalWinUpdateOp and the
// superseded-operation-resolver). Both pass the canonical entityId — so the
// payload should always carry it, even when entityState was malformed.
it('should backfill payload.id from entityId when entityState lacks id', () => {
const entityStateWithoutId = { title: 'Local winner', projectId: 'proj-1' };
const op = service.createLWWUpdateOp(
'TASK',
'task-canonical',
entityStateWithoutId,
TEST_CLIENT_ID,
{ [TEST_CLIENT_ID]: 1 },
Date.now(),
);
expect((op.payload as Record<string, unknown>)['id']).toBe('task-canonical');
});
it('should overwrite a mismatched payload.id with the canonical entityId', () => {
const entityStateWithStaleId = {
id: 'wrong-id',
title: 'Local winner',
};
const op = service.createLWWUpdateOp(
'TASK',
'task-canonical',
entityStateWithStaleId,
TEST_CLIENT_ID,
{ [TEST_CLIENT_ID]: 1 },
Date.now(),
);
expect((op.payload as Record<string, unknown>)['id']).toBe('task-canonical');
});
it('should handle non-object entityState by producing { id } payload', () => {
// Defensive: producers should never pass non-objects, but if they do
// we still want a usable payload rather than something the reducer rejects.
const op = service.createLWWUpdateOp(
'TASK',
'task-canonical',
undefined,
TEST_CLIENT_ID,
{ [TEST_CLIENT_ID]: 1 },
Date.now(),
);
expect((op.payload as Record<string, unknown>)['id']).toBe('task-canonical');
});
it('should ensure _convertToLWWUpdatesIfNeeded merged payload has id even when base entity lacks id', () => {
// Edge case: a malformed local DELETE payload whose embedded entity
// somehow lacks an id (e.g. corruption). The merged LWW Update payload
// should still carry id from conflict.entityId.
const localClientId = 'client-a';
const remoteClientId = 'client-b';
const conflict: EntityConflict = {
entityType: 'TASK',
entityId: 'task-1',
suggestedResolution: 'manual',
localOps: [
{
id: 'local-del',
clientId: localClientId,
actionType: 'test' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { title: 'No id here', projectId: 'proj-1' } },
vectorClock: { [localClientId]: 1 },
timestamp: Date.now() - 1000,
schemaVersion: 1,
},
],
remoteOps: [
{
id: 'remote-upd',
clientId: remoteClientId,
actionType: 'test' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { id: 'task-1', changes: { title: 'New title' } } },
vectorClock: { [remoteClientId]: 1 },
timestamp: Date.now(),
schemaVersion: 1,
},
],
};
const result = (service as any)._convertToLWWUpdatesIfNeeded(conflict);
expect(result.length).toBe(1);
expect(result[0].actionType).toBe('[TASK] LWW Update');
expect(result[0].payload.id).toBe('task-1');
expect(result[0].payload.title).toBe('New title');
expect(result[0].payload.projectId).toBe('proj-1');
});
// Singletons (GLOBAL_CONFIG, app-state, time-tracking) use entityId='*'
// as a sentinel. Injecting `id: '*'` into the payload would pollute the
// singleton feature state — which has no `id` field — when the consumer
// reducer spreads entityData into the feature state.
it('should NOT inject id when entityId is the singleton sentinel "*"', () => {
const singletonState = { sync: { syncProvider: null }, misc: { foo: 'bar' } };
const op = service.createLWWUpdateOp(
'GLOBAL_CONFIG',
'*',
singletonState,
TEST_CLIENT_ID,
{ [TEST_CLIENT_ID]: 1 },
Date.now(),
);
expect((op.payload as Record<string, unknown>)['id']).toBeUndefined();
// Original payload shape preserved (no synthetic field injected).
expect(op.payload).toEqual(singletonState);
});
});
});