super-productivity/src/app/op-log/testing/integration/lww-conflict-resolution.integration.spec.ts
Johannes Millan 527bb229ac test(sync): add integration test for entity extraction from DELETE payload
Add integration test verifying that when a remote DELETE is applied first
but a local UPDATE wins LWW conflict resolution, the entity can be
extracted from the DELETE payload and the UPDATE applied on top of it.
2026-01-22 13:34:54 +01:00

1558 lines
55 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { OperationLogStoreService } from '../../persistence/operation-log-store.service';
import { ActionType, OpType } from '../../core/operation.types';
import {
compareVectorClocks,
VectorClockComparison,
mergeVectorClocks,
incrementVectorClock,
} from '../../../core/util/vector-clock';
import { TestClient, resetTestUuidCounter } from './helpers/test-client.helper';
import { createTaskOperation } from './helpers/operation-factory.helper';
/**
* Integration tests for Last-Write-Wins (LWW) conflict resolution.
*
* These tests verify the LWW resolution strategy at the store level:
* - Timestamp comparison determines winner
* - When local wins, new update operation is created
* - Vector clocks are properly merged
* - Both sides' operations are handled correctly
*
* IMPORTANT: These tests focus on the store-level behavior and conflict
* detection logic. Full application-level LWW testing is done in E2E tests.
*/
describe('LWW Conflict Resolution Integration', () => {
let storeService: OperationLogStoreService;
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [OperationLogStoreService],
});
storeService = TestBed.inject(OperationLogStoreService);
await storeService.init();
await storeService._clearAllDataForTesting();
resetTestUuidCounter();
});
describe('LWW timestamp comparison', () => {
it('should identify remote as winner when remote timestamp is newer', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A creates older operation
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
// Manually set timestamp to older time
(opA as any).timestamp = now - 5000;
// Client B creates newer operation
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
// Manually set timestamp to newer time
(opB as any).timestamp = now;
// LWW: Compare timestamps
const localMax = opA.timestamp;
const remoteMax = opB.timestamp;
expect(remoteMax).toBeGreaterThan(localMax);
// Remote (B) should win
});
it('should identify local as winner when local timestamp is newer', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A creates newer operation (local)
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
(opA as any).timestamp = now;
// Client B creates older operation (remote)
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
(opB as any).timestamp = now - 5000;
// LWW: Compare timestamps
const localMax = opA.timestamp;
const remoteMax = opB.timestamp;
expect(localMax).toBeGreaterThan(remoteMax);
// Local (A) should win
});
it('should use remote as tie-breaker when timestamps are equal', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Both clients create operations at same timestamp
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
(opA as any).timestamp = now;
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
(opB as any).timestamp = now;
// LWW: Tie-breaker rule - remote wins
const localMax = opA.timestamp;
const remoteMax = opB.timestamp;
expect(localMax).toBe(remoteMax);
// Convention: remote wins on tie (server-authoritative)
});
});
describe('Concurrent edit detection', () => {
it('should detect concurrent edits by vector clock comparison', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
// Both clients start from "no knowledge of each other"
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
// Vector clocks should be CONCURRENT (neither dominates)
const comparison = compareVectorClocks(opA.vectorClock, opB.vectorClock);
expect(comparison).toBe(VectorClockComparison.CONCURRENT);
});
it('should detect sequential updates (not conflicting)', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
// Client A creates operation
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
// Client B receives A's operation and merges clock
clientB.mergeRemoteClock(opA.vectorClock);
// Client B then creates its own operation (with knowledge of A's)
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
// B's operation should be GREATER_THAN A's (sequential, not concurrent)
const comparison = compareVectorClocks(opB.vectorClock, opA.vectorClock);
expect(comparison).toBe(VectorClockComparison.GREATER_THAN);
});
});
describe('Vector clock merging for LWW local wins', () => {
it('should correctly merge vector clocks when creating update op for local win', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
// Simulate: Client A has local ops, Client B has remote ops
const localOp = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local title' },
});
const remoteOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Remote title' },
});
// When local wins LWW, we merge clocks and increment
const mergedClock = mergeVectorClocks(localOp.vectorClock, remoteOp.vectorClock);
const newClock = incrementVectorClock(mergedClock, 'client-a-test');
// New clock should have both clients' knowledge + increment for A
expect(newClock['client-a-test']).toBe(2); // A's original 1 + increment
expect(newClock['client-b-test']).toBe(1); // B's original 1
});
it('should create update op that dominates both original ops', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const localOp = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local title' },
});
const remoteOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Remote title' },
});
// Create new update op with merged + incremented clock
const mergedClock = mergeVectorClocks(localOp.vectorClock, remoteOp.vectorClock);
const newClock = incrementVectorClock(mergedClock, 'client-a-test');
// The new clock should be GREATER_THAN both original ops
const vsLocal = compareVectorClocks(newClock, localOp.vectorClock);
const vsRemote = compareVectorClocks(newClock, remoteOp.vectorClock);
expect(vsLocal).toBe(VectorClockComparison.GREATER_THAN);
expect(vsRemote).toBe(VectorClockComparison.GREATER_THAN);
});
});
describe('Rejected operations handling', () => {
it('should mark local ops as rejected when remote wins', async () => {
const clientA = new TestClient('client-a-test');
const op = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Local title',
});
// Store as local op
await storeService.append(op, 'local');
// Mark as rejected (simulating remote wins LWW)
await storeService.markRejected([op.id]);
// Verify op is marked as rejected
const entry = await storeService.getOpById(op.id);
expect(entry).toBeDefined();
expect(entry!.rejectedAt).toBeDefined();
// Rejected ops should NOT appear in unsynced
const unsynced = await storeService.getUnsynced();
expect(unsynced.find((e) => e.op.id === op.id)).toBeUndefined();
});
it('should mark both local and remote ops as rejected when local wins', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const localOp = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Local title',
});
const remoteOp = createTaskOperation(clientB, 'task-1', OpType.Update, {
title: 'Remote title',
});
// Store both ops
await storeService.append(localOp, 'local');
await storeService.append(remoteOp, 'remote');
// When local wins LWW, both old ops are rejected
await storeService.markRejected([localOp.id, remoteOp.id]);
// Verify both are rejected
const localEntry = await storeService.getOpById(localOp.id);
const remoteEntry = await storeService.getOpById(remoteOp.id);
expect(localEntry!.rejectedAt).toBeDefined();
expect(remoteEntry!.rejectedAt).toBeDefined();
});
});
describe('Multiple ops per side', () => {
it('should use max timestamp when local has multiple ops', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Local has older first op, newer second op
const localOp1 = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local v1' },
});
(localOp1 as any).timestamp = now - 10000;
const localOp2 = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Local v2' },
});
(localOp2 as any).timestamp = now;
// Remote has one op in between
const remoteOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Remote' },
});
(remoteOp as any).timestamp = now - 5000;
// LWW: max(local timestamps) vs max(remote timestamps)
const localMax = Math.max(localOp1.timestamp, localOp2.timestamp);
const remoteMax = remoteOp.timestamp;
expect(localMax).toBeGreaterThan(remoteMax);
// Local wins because localOp2 (newest) is newer than remoteOp
});
});
describe('Three-client convergence', () => {
it('should handle conflicts from three clients using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const clientC = new TestClient('client-c-test');
const now = Date.now();
// All three clients edit same entity concurrently
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
(opA as any).timestamp = now - 2000;
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
(opB as any).timestamp = now - 1000;
const opC = clientC.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from C' },
});
(opC as any).timestamp = now;
// All three are concurrent (no knowledge of each other)
expect(compareVectorClocks(opA.vectorClock, opB.vectorClock)).toBe(
VectorClockComparison.CONCURRENT,
);
expect(compareVectorClocks(opB.vectorClock, opC.vectorClock)).toBe(
VectorClockComparison.CONCURRENT,
);
expect(compareVectorClocks(opA.vectorClock, opC.vectorClock)).toBe(
VectorClockComparison.CONCURRENT,
);
// LWW: C wins (newest timestamp)
const maxTimestamp = Math.max(opA.timestamp, opB.timestamp, opC.timestamp);
expect(maxTimestamp).toBe(opC.timestamp);
});
});
describe('Delete vs Update conflict', () => {
it('should resolve delete vs update using LWW timestamps', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A deletes task (newer timestamp)
const deleteOp = clientA.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: null,
});
(deleteOp as any).timestamp = now;
// Client B updates task (older timestamp)
const updateOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = now - 5000;
// LWW: Delete wins (newer timestamp)
expect(deleteOp.timestamp).toBeGreaterThan(updateOp.timestamp);
// Both are concurrent (no vector clock knowledge of each other)
const comparison = compareVectorClocks(deleteOp.vectorClock, updateOp.vectorClock);
expect(comparison).toBe(VectorClockComparison.CONCURRENT);
});
it('should resolve update vs delete when update is newer', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A updates task (newer timestamp)
const updateOp = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = now;
// Client B deletes task (older timestamp)
const deleteOp = clientB.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: null,
});
(deleteOp as any).timestamp = now - 5000;
// LWW: Update wins (newer timestamp)
expect(updateOp.timestamp).toBeGreaterThan(deleteOp.timestamp);
});
});
describe('Stale pending ops handling', () => {
it('should reject all pending ops for an entity when remote wins', async () => {
const clientA = new TestClient('client-a-test');
// Create multiple pending ops for the same entity
const op1 = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Update 1',
});
const op2 = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Update 2',
});
const op3 = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Update 3',
});
// Store all ops as local (pending upload)
await storeService.append(op1, 'local');
await storeService.append(op2, 'local');
await storeService.append(op3, 'local');
// Verify all are in unsynced
let unsynced = await storeService.getUnsynced();
expect(unsynced.length).toBe(3);
// When remote wins LWW, ALL pending ops for that entity should be rejected
await storeService.markRejected([op1.id, op2.id, op3.id]);
// Verify none appear in unsynced
unsynced = await storeService.getUnsynced();
expect(unsynced.length).toBe(0);
// But all should still exist in the log (for history)
const entry1 = await storeService.getOpById(op1.id);
const entry2 = await storeService.getOpById(op2.id);
const entry3 = await storeService.getOpById(op3.id);
expect(entry1).toBeDefined();
expect(entry2).toBeDefined();
expect(entry3).toBeDefined();
expect(entry1!.rejectedAt).toBeDefined();
expect(entry2!.rejectedAt).toBeDefined();
expect(entry3!.rejectedAt).toBeDefined();
});
});
describe('New update op after local wins', () => {
it('should create new op that can be appended after rejecting old ops', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
// Simulate conflict scenario
const localOp = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Local title',
});
const remoteOp = createTaskOperation(clientB, 'task-1', OpType.Update, {
title: 'Remote title',
});
await storeService.append(localOp, 'local');
await storeService.append(remoteOp, 'remote');
// Reject both old ops (simulating local wins LWW)
await storeService.markRejected([localOp.id, remoteOp.id]);
// Create new update op with merged clock
const mergedClock = mergeVectorClocks(localOp.vectorClock, remoteOp.vectorClock);
const newClock = incrementVectorClock(mergedClock, clientA.clientId);
const newUpdateOp = clientA.createOperation({
actionType: '[Task] LWW Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Final local title' },
});
// Override the vector clock with the properly merged one
(newUpdateOp as any).vectorClock = newClock;
// Append the new op
await storeService.append(newUpdateOp, 'local');
// Verify only the new op appears in unsynced
const unsynced = await storeService.getUnsynced();
expect(unsynced.length).toBe(1);
expect(unsynced[0].op.id).toBe(newUpdateOp.id);
// Verify the new op's vector clock dominates the old ones
const vsLocal = compareVectorClocks(newUpdateOp.vectorClock, localOp.vectorClock);
const vsRemote = compareVectorClocks(newUpdateOp.vectorClock, remoteOp.vectorClock);
expect(vsLocal).toBe(VectorClockComparison.GREATER_THAN);
expect(vsRemote).toBe(VectorClockComparison.GREATER_THAN);
});
});
describe('Entity frontier isolation', () => {
it('should not affect unrelated entities when rejecting ops', async () => {
const clientA = new TestClient('client-a-test');
// Create ops for different entities
const op1 = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Task 1',
});
const op2 = createTaskOperation(clientA, 'task-2', OpType.Update, {
title: 'Task 2',
});
const op3 = createTaskOperation(clientA, 'task-3', OpType.Update, {
title: 'Task 3',
});
await storeService.append(op1, 'local');
await storeService.append(op2, 'local');
await storeService.append(op3, 'local');
// Reject only task-1's op
await storeService.markRejected([op1.id]);
// Task 2 and 3 ops should still be in unsynced
const unsynced = await storeService.getUnsynced();
expect(unsynced.length).toBe(2);
expect(unsynced.find((e) => e.op.entityId === 'task-2')).toBeDefined();
expect(unsynced.find((e) => e.op.entityId === 'task-3')).toBeDefined();
expect(unsynced.find((e) => e.op.entityId === 'task-1')).toBeUndefined();
});
it('should allow new ops for same entity after rejection', async () => {
const clientA = new TestClient('client-a-test');
// Create and reject an op
const oldOp = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'Old title',
});
await storeService.append(oldOp, 'local');
await storeService.markRejected([oldOp.id]);
// Create a new op for the same entity
const newOp = createTaskOperation(clientA, 'task-1', OpType.Update, {
title: 'New title',
});
await storeService.append(newOp, 'local');
// The new op should be in unsynced
const unsynced = await storeService.getUnsynced();
expect(unsynced.length).toBe(1);
expect(unsynced[0].op.id).toBe(newOp.id);
});
});
describe('Millisecond precision timestamps', () => {
it('should correctly compare timestamps with millisecond differences', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Operations with only 1ms difference
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
(opA as any).timestamp = now;
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
(opB as any).timestamp = now + 1; // Just 1ms newer
// LWW: B wins (even by 1ms)
expect(opB.timestamp).toBeGreaterThan(opA.timestamp);
expect(opB.timestamp - opA.timestamp).toBe(1);
});
});
describe('Empty ops array edge cases', () => {
it('should handle conflict with empty localOps array', async () => {
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Remote op only
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from B' },
});
(opB as any).timestamp = now;
// LWW with empty localOps: Math.max() returns -Infinity
const localOps: any[] = [];
const localMaxTimestamp =
localOps.length > 0 ? Math.max(...localOps.map((op) => op.timestamp)) : -Infinity;
const remoteMaxTimestamp = opB.timestamp;
// Remote should always win when local is empty
expect(remoteMaxTimestamp).toBeGreaterThan(localMaxTimestamp);
});
it('should handle conflict with empty remoteOps array', async () => {
const clientA = new TestClient('client-a-test');
const now = Date.now();
// Local op only
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Title from A' },
});
(opA as any).timestamp = now;
// LWW with empty remoteOps: Math.max() returns -Infinity
const remoteOps: any[] = [];
const localMaxTimestamp = opA.timestamp;
const remoteMaxTimestamp =
remoteOps.length > 0
? Math.max(...remoteOps.map((op) => op.timestamp))
: -Infinity;
// Local should always win when remote is empty
expect(localMaxTimestamp).toBeGreaterThan(remoteMaxTimestamp);
});
});
describe('DELETE vs UPDATE conflict scenarios', () => {
it('should extract entity from DELETE payload when UPDATE wins LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Create a complete task entity for the DELETE payload
const fullTaskEntity = {
id: 'task-restore',
title: 'Original Task Title',
created: now - 10000,
timeSpent: 3600000,
timeEstimate: 7200000,
isDone: false,
notes: 'Important notes',
tagIds: ['tag-1', 'tag-2'],
subTaskIds: [],
attachments: [],
projectId: 'project-1',
};
// Client A deletes task (older, remote) - with FULL entity in payload
const deleteOp = clientA.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-restore',
payload: fullTaskEntity, // DELETE contains full entity for potential restoration
});
(deleteOp as any).timestamp = now - 1000;
// Client B updates same task (newer, local)
const updateOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-restore',
payload: { title: 'Updated Title After Delete' },
});
(updateOp as any).timestamp = now;
// LWW: UPDATE wins (newer timestamp)
expect(updateOp.timestamp).toBeGreaterThan(deleteOp.timestamp);
// Simulate: DELETE was applied first (entity removed from store)
// Then sync discovers UPDATE should win
// System must extract entity from DELETE payload to restore it
// Store both ops
await storeService.append(deleteOp, 'remote');
await storeService.append(updateOp, 'local');
// Verify DELETE has full entity in payload (available for extraction)
const storedDeleteEntry = await storeService.getOpById(deleteOp.id);
expect(storedDeleteEntry?.op.payload).toBeDefined();
expect((storedDeleteEntry?.op.payload as any).title).toBe('Original Task Title');
expect((storedDeleteEntry?.op.payload as any).id).toBe('task-restore');
// Both operations are concurrent (vector clocks don't dominate)
const comparison = compareVectorClocks(deleteOp.vectorClock, updateOp.vectorClock);
expect(comparison).toBe(VectorClockComparison.CONCURRENT);
// Mark DELETE as rejected since UPDATE wins
await storeService.markRejected([deleteOp.id]);
// Verify the entity from DELETE payload is available for extraction
// This simulates the ConflictResolutionService extracting the entity
// and applying the UPDATE on top of it
const extractedEntity = storedDeleteEntry?.op.payload as Record<string, unknown>;
expect(extractedEntity).toBeDefined();
expect(extractedEntity.title).toBe('Original Task Title');
expect(extractedEntity.timeSpent).toBe(3600000);
// The UPDATE should be applied on top of the extracted entity
const finalEntity = {
...extractedEntity,
...(updateOp.payload as Record<string, unknown>),
} as Record<string, unknown>;
expect(finalEntity.title).toBe('Updated Title After Delete');
expect(finalEntity.timeSpent).toBe(3600000); // Preserved from DELETE
expect(finalEntity.tagIds).toEqual(['tag-1', 'tag-2']); // Preserved from DELETE
});
it('should resolve DELETE vs UPDATE when DELETE is newer (local wins)', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A deletes (newer)
const deleteOp = clientA.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: {},
});
(deleteOp as any).timestamp = now;
// Client B updates (older)
const updateOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = now - 1000;
// LWW: DELETE wins (newer)
expect(deleteOp.timestamp).toBeGreaterThan(updateOp.timestamp);
expect(deleteOp.opType).toBe(OpType.Delete);
// Store both ops
await storeService.append(deleteOp, 'local');
await storeService.append(updateOp, 'remote');
// Mark both as rejected (LWW local wins behavior)
await storeService.markRejected([deleteOp.id, updateOp.id]);
// Verify both are rejected
const deleteEntry = await storeService.getOpById(deleteOp.id);
const updateEntry = await storeService.getOpById(updateOp.id);
expect(deleteEntry?.rejectedAt).toBeDefined();
expect(updateEntry?.rejectedAt).toBeDefined();
});
it('should resolve UPDATE vs DELETE when UPDATE is newer (local wins)', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A updates (newer)
const updateOp = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = now;
// Client B deletes (older)
const deleteOp = clientB.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: {},
});
(deleteOp as any).timestamp = now - 1000;
// LWW: UPDATE wins (newer)
expect(updateOp.timestamp).toBeGreaterThan(deleteOp.timestamp);
// Store and handle as local wins
await storeService.append(updateOp, 'local');
await storeService.append(deleteOp, 'remote');
await storeService.markRejected([updateOp.id, deleteOp.id]);
// Both ops should be rejected
const updateEntry = await storeService.getOpById(updateOp.id);
const deleteEntry = await storeService.getOpById(deleteOp.id);
expect(updateEntry?.rejectedAt).toBeDefined();
expect(deleteEntry?.rejectedAt).toBeDefined();
});
it('should resolve DELETE vs UPDATE when UPDATE is newer (remote wins)', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A deletes (older, local)
const deleteOp = clientA.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-1',
payload: {},
});
(deleteOp as any).timestamp = now - 1000;
// Client B updates (newer, remote)
const updateOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = now;
// LWW: remote UPDATE wins (newer)
expect(updateOp.timestamp).toBeGreaterThan(deleteOp.timestamp);
// Store and mark local rejected, remote applied
await storeService.append(deleteOp, 'local');
await storeService.markRejected([deleteOp.id]);
const seq = await storeService.append(updateOp, 'remote', { pendingApply: true });
await storeService.markApplied([seq]);
// Verify states
const deleteEntry = await storeService.getOpById(deleteOp.id);
const updateEntry = await storeService.getOpById(updateOp.id);
expect(deleteEntry?.rejectedAt).toBeDefined();
expect(updateEntry?.appliedAt).toBeDefined();
});
});
describe('CREATE vs CREATE conflict', () => {
it('should handle two clients creating entity with same ID', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
const sharedEntityId = 'task-shared-id';
// Client A creates (older)
const createA = clientA.createOperation({
actionType: '[Task] Create Task' as ActionType,
opType: OpType.Create,
entityType: 'TASK',
entityId: sharedEntityId,
payload: { title: 'Created by A', notes: 'A notes' },
});
(createA as any).timestamp = now - 1000;
// Client B creates same ID (newer)
const createB = clientB.createOperation({
actionType: '[Task] Create Task' as ActionType,
opType: OpType.Create,
entityType: 'TASK',
entityId: sharedEntityId,
payload: { title: 'Created by B', notes: 'B notes' },
});
(createB as any).timestamp = now;
// LWW: B's CREATE wins (newer)
expect(createB.timestamp).toBeGreaterThan(createA.timestamp);
// Both are CREATE operations on same entity
expect(createA.opType).toBe(OpType.Create);
expect(createB.opType).toBe(OpType.Create);
expect(createA.entityId).toBe(createB.entityId);
// Store both - remote with pendingApply flag
await storeService.append(createA, 'local');
const seq = await storeService.append(createB, 'remote', { pendingApply: true });
// Remote wins: reject local, apply remote
await storeService.markRejected([createA.id]);
await storeService.markApplied([seq]);
const entryA = await storeService.getOpById(createA.id);
const entryB = await storeService.getOpById(createB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.applicationStatus).toBe('applied');
});
});
describe('MOVE operation conflicts', () => {
it('should resolve MOV vs MOV using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A moves task (newer, local)
const movA = clientA.createOperation({
actionType: '[Task] Move Task' as ActionType,
opType: OpType.Move,
entityType: 'TASK',
entityId: 'task-1',
payload: { fromIndex: 0, toIndex: 5, targetProjectId: 'project-1' },
});
(movA as any).timestamp = now;
// Client B moves same task (older, remote)
const movB = clientB.createOperation({
actionType: '[Task] Move Task' as ActionType,
opType: OpType.Move,
entityType: 'TASK',
entityId: 'task-1',
payload: { fromIndex: 0, toIndex: 10, targetProjectId: 'project-2' },
});
(movB as any).timestamp = now - 1000;
// LWW: A's MOV wins (newer)
expect(movA.timestamp).toBeGreaterThan(movB.timestamp);
// Store both
await storeService.append(movA, 'local');
await storeService.append(movB, 'remote');
// Local wins: reject both, keep local state
await storeService.markRejected([movA.id, movB.id]);
const entryA = await storeService.getOpById(movA.id);
const entryB = await storeService.getOpById(movB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.rejectedAt).toBeDefined();
});
});
describe('BATCH operation conflicts', () => {
it('should resolve BATCH vs BATCH using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A batch updates (older, local)
const batchA = clientA.createOperation({
actionType: '[Task] Batch Update' as ActionType,
opType: OpType.Batch,
entityType: 'TASK',
entityId: 'task-1',
entityIds: ['task-1', 'task-2', 'task-3'],
payload: { done: true },
});
(batchA as any).timestamp = now - 1000;
// Client B batch updates (newer, remote)
const batchB = clientB.createOperation({
actionType: '[Task] Batch Update' as ActionType,
opType: OpType.Batch,
entityType: 'TASK',
entityId: 'task-1',
entityIds: ['task-1', 'task-2'],
payload: { done: false },
});
(batchB as any).timestamp = now;
// LWW: B's BATCH wins (newer)
expect(batchB.timestamp).toBeGreaterThan(batchA.timestamp);
// Store both - remote with pendingApply flag
await storeService.append(batchA, 'local');
const seq = await storeService.append(batchB, 'remote', { pendingApply: true });
// Remote wins: reject local, apply remote
await storeService.markRejected([batchA.id]);
await storeService.markApplied([seq]);
const entryA = await storeService.getOpById(batchA.id);
const entryB = await storeService.getOpById(batchB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.applicationStatus).toBe('applied');
});
it('should handle overlapping entity IDs in BATCH conflicts', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A batch affects tasks 1, 2, 3
const batchA = clientA.createOperation({
actionType: '[Task] Batch Update' as ActionType,
opType: OpType.Batch,
entityType: 'TASK',
entityId: 'task-1',
entityIds: ['task-1', 'task-2', 'task-3'],
payload: { priority: 'high' },
});
(batchA as any).timestamp = now;
// Client B batch affects tasks 2, 3, 4 (overlap on 2, 3)
const batchB = clientB.createOperation({
actionType: '[Task] Batch Update' as ActionType,
opType: OpType.Batch,
entityType: 'TASK',
entityId: 'task-2',
entityIds: ['task-2', 'task-3', 'task-4'],
payload: { priority: 'low' },
});
(batchB as any).timestamp = now - 500;
// LWW: A wins (newer)
expect(batchA.timestamp).toBeGreaterThan(batchB.timestamp);
// Store both
await storeService.append(batchA, 'local');
await storeService.append(batchB, 'remote');
// Local wins: reject both
await storeService.markRejected([batchA.id, batchB.id]);
// Both should be rejected
const entryA = await storeService.getOpById(batchA.id);
const entryB = await storeService.getOpById(batchB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.rejectedAt).toBeDefined();
});
});
describe('Singleton entity conflicts (GLOBAL_CONFIG)', () => {
it('should resolve GLOBAL_CONFIG conflicts using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A updates config (newer, local)
const configA = clientA.createOperation({
actionType: '[GlobalConfig] Update' as ActionType,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'GLOBAL_CONFIG',
payload: { misc: { isDarkMode: true } },
});
(configA as any).timestamp = now;
// Client B updates config (older, remote)
const configB = clientB.createOperation({
actionType: '[GlobalConfig] Update' as ActionType,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'GLOBAL_CONFIG',
payload: { misc: { isDarkMode: false } },
});
(configB as any).timestamp = now - 1000;
// LWW: A wins (newer)
expect(configA.timestamp).toBeGreaterThan(configB.timestamp);
expect(configA.entityType).toBe('GLOBAL_CONFIG');
expect(configB.entityType).toBe('GLOBAL_CONFIG');
// Store both
await storeService.append(configA, 'local');
await storeService.append(configB, 'remote');
// Local wins: reject both
await storeService.markRejected([configA.id, configB.id]);
const entryA = await storeService.getOpById(configA.id);
const entryB = await storeService.getOpById(configB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.rejectedAt).toBeDefined();
});
});
describe('Complex entity conflicts (PLANNER, BOARD)', () => {
it('should resolve PLANNER day conflicts using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
const dayId = '2024-01-15';
// Client A updates planner day (older, remote)
const plannerA = clientA.createOperation({
actionType: '[Planner] Update Day' as ActionType,
opType: OpType.Update,
entityType: 'PLANNER',
entityId: dayId,
payload: { scheduledTaskIds: ['task-1', 'task-2'] },
});
(plannerA as any).timestamp = now - 1000;
// Client B updates same day (newer, local)
const plannerB = clientB.createOperation({
actionType: '[Planner] Update Day' as ActionType,
opType: OpType.Update,
entityType: 'PLANNER',
entityId: dayId,
payload: { scheduledTaskIds: ['task-3', 'task-4'] },
});
(plannerB as any).timestamp = now;
// LWW: B wins (newer)
expect(plannerB.timestamp).toBeGreaterThan(plannerA.timestamp);
expect(plannerA.entityType).toBe('PLANNER');
expect(plannerA.entityId).toBe(dayId);
// Store both
await storeService.append(plannerB, 'local');
await storeService.append(plannerA, 'remote');
// Local wins
await storeService.markRejected([plannerB.id, plannerA.id]);
const entryA = await storeService.getOpById(plannerA.id);
const entryB = await storeService.getOpById(plannerB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.rejectedAt).toBeDefined();
});
it('should resolve BOARD conflicts using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A updates board (newer, remote)
const boardA = clientA.createOperation({
actionType: '[Boards] Update Board' as ActionType,
opType: OpType.Update,
entityType: 'BOARD',
entityId: 'board-1',
payload: { title: 'Board from A', columns: ['todo', 'doing', 'done'] },
});
(boardA as any).timestamp = now;
// Client B updates board (older, local)
const boardB = clientB.createOperation({
actionType: '[Boards] Update Board' as ActionType,
opType: OpType.Update,
entityType: 'BOARD',
entityId: 'board-1',
payload: { title: 'Board from B', columns: ['backlog', 'in-progress'] },
});
(boardB as any).timestamp = now - 1000;
// LWW: A wins (newer)
expect(boardA.timestamp).toBeGreaterThan(boardB.timestamp);
expect(boardA.entityType).toBe('BOARD');
// Store both - remote with pendingApply flag
await storeService.append(boardB, 'local');
const seq = await storeService.append(boardA, 'remote', { pendingApply: true });
// Remote wins: reject local, apply remote
await storeService.markRejected([boardB.id]);
await storeService.markApplied([seq]);
const entryA = await storeService.getOpById(boardA.id);
const entryB = await storeService.getOpById(boardB.id);
expect(entryB?.rejectedAt).toBeDefined();
expect(entryA?.applicationStatus).toBe('applied');
});
it('should resolve REMINDER conflicts using LWW', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Client A updates reminder (older)
const reminderA = clientA.createOperation({
actionType: '[Reminder] Update' as ActionType,
opType: OpType.Update,
entityType: 'REMINDER',
entityId: 'reminder-1',
payload: { title: 'Reminder A', remindAt: now + 3600000 },
});
(reminderA as any).timestamp = now - 2000;
// Client B updates reminder (newer)
const reminderB = clientB.createOperation({
actionType: '[Reminder] Update' as ActionType,
opType: OpType.Update,
entityType: 'REMINDER',
entityId: 'reminder-1',
payload: { title: 'Reminder B', remindAt: now + 7200000 },
});
(reminderB as any).timestamp = now;
// LWW: B wins (newer)
expect(reminderB.timestamp).toBeGreaterThan(reminderA.timestamp);
expect(reminderA.entityType).toBe('REMINDER');
// Store both - remote with pendingApply flag
await storeService.append(reminderA, 'local');
const seq = await storeService.append(reminderB, 'remote', { pendingApply: true });
// Remote wins
await storeService.markRejected([reminderA.id]);
await storeService.markApplied([seq]);
const entryA = await storeService.getOpById(reminderA.id);
const entryB = await storeService.getOpById(reminderB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.applicationStatus).toBe('applied');
});
});
describe('Same timestamp edge cases', () => {
it('should handle exact same timestamp with different payloads consistently', async () => {
const clientA = new TestClient('client-a-ts');
const clientB = new TestClient('client-b-ts');
const exactTimestamp = 1700000000000; // Fixed timestamp for reproducibility
// Create operations with exact same timestamp
const opA = clientA.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-tie',
payload: { title: 'A wins?', notes: 'from A' },
});
(opA as any).timestamp = exactTimestamp;
const opB = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-tie',
payload: { title: 'B wins?', notes: 'from B' },
});
(opB as any).timestamp = exactTimestamp;
// Both have exact same timestamp
expect(opA.timestamp).toBe(opB.timestamp);
// Store operations
await storeService.append(opA, 'local');
await storeService.append(opB, 'remote', { pendingApply: true });
// Convention: remote wins on tie
// This test documents the expected behavior
const localEntry = await storeService.getOpById(opA.id);
const remoteEntry = await storeService.getOpById(opB.id);
expect(localEntry).toBeDefined();
expect(remoteEntry).toBeDefined();
});
it('should handle Delete vs Update conflict at same timestamp (Update wins)', async () => {
const clientA = new TestClient('client-delete');
const clientB = new TestClient('client-update');
const sameTime = Date.now();
// Client A deletes task
const deleteOp = clientA.createOperation({
actionType: '[Task] Delete Task' as ActionType,
opType: OpType.Delete,
entityType: 'TASK',
entityId: 'task-conflict',
payload: {},
});
(deleteOp as any).timestamp = sameTime;
// Client B updates same task
const updateOp = clientB.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-conflict',
payload: { title: 'Updated title' },
});
(updateOp as any).timestamp = sameTime;
await storeService.append(deleteOp, 'local');
const updateSeq = await storeService.append(updateOp, 'remote', {
pendingApply: true,
});
// LWW heuristic: Update wins over Delete (preserve data)
// Mark delete as rejected, update as applied
await storeService.markRejected([deleteOp.id]);
await storeService.markApplied([updateSeq]);
const deleteEntry = await storeService.getOpById(deleteOp.id);
const updateEntry = await storeService.getOpById(updateOp.id);
expect(deleteEntry?.rejectedAt).toBeDefined();
expect(updateEntry?.applicationStatus).toBe('applied');
});
it('should handle Create vs Create conflict (should not happen but gracefully handle)', async () => {
const clientA = new TestClient('client-create-a');
const clientB = new TestClient('client-create-b');
const sameTime = Date.now();
const sameEntityId = 'new-task-123'; // Same ID by coincidence (extremely rare)
// Both clients create task with same ID (very rare edge case)
const createA = clientA.createOperation({
actionType: '[Task] Add Task' as ActionType,
opType: OpType.Create,
entityType: 'TASK',
entityId: sameEntityId,
payload: { id: sameEntityId, title: 'Task from A' },
});
(createA as any).timestamp = sameTime;
const createB = clientB.createOperation({
actionType: '[Task] Add Task' as ActionType,
opType: OpType.Create,
entityType: 'TASK',
entityId: sameEntityId,
payload: { id: sameEntityId, title: 'Task from B' },
});
(createB as any).timestamp = sameTime;
await storeService.append(createA, 'local');
const createBSeq = await storeService.append(createB, 'remote', {
pendingApply: true,
});
// In Create vs Create, remote wins (convention)
await storeService.markRejected([createA.id]);
await storeService.markApplied([createBSeq]);
const entryA = await storeService.getOpById(createA.id);
const entryB = await storeService.getOpById(createB.id);
expect(entryA?.rejectedAt).toBeDefined();
expect(entryB?.applicationStatus).toBe('applied');
});
it('should handle rapid-fire updates from same client', async () => {
const client = new TestClient('rapid-client');
const baseTime = Date.now();
// Create 5 rapid updates (might have same ms timestamp)
for (let i = 0; i < 5; i++) {
const op = client.createOperation({
actionType: '[Task] Update Task' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'rapid-task',
payload: { title: `Update ${i}` },
});
(op as any).timestamp = baseTime; // Same timestamp for all
await storeService.append(op, 'local');
}
// All ops should be stored successfully
const allOps = await storeService.getOpsAfterSeq(0);
expect(allOps.length).toBe(5);
// All should be from same client
for (const entry of allOps) {
expect(entry.op.clientId).toBe('rapid-client');
}
});
});
describe('Mixed operations and entity types', () => {
it('should handle multiple entity types in a single LWW resolution batch', async () => {
const clientA = new TestClient('client-a-test');
const clientB = new TestClient('client-b-test');
const now = Date.now();
// Task conflict: remote wins
const taskA = clientA.createOperation({
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Task A' },
});
(taskA as any).timestamp = now - 1000;
const taskB = clientB.createOperation({
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { title: 'Task B' },
});
(taskB as any).timestamp = now;
// Project conflict: local wins
const projectA = clientA.createOperation({
actionType: '[Project] Update' as ActionType,
opType: OpType.Update,
entityType: 'PROJECT',
entityId: 'project-1',
payload: { title: 'Project A' },
});
(projectA as any).timestamp = now;
const projectB = clientB.createOperation({
actionType: '[Project] Update' as ActionType,
opType: OpType.Update,
entityType: 'PROJECT',
entityId: 'project-1',
payload: { title: 'Project B' },
});
(projectB as any).timestamp = now - 1000;
// Tag conflict: tie (remote wins)
const tagA = clientA.createOperation({
actionType: '[Tag] Update' as ActionType,
opType: OpType.Update,
entityType: 'TAG',
entityId: 'tag-1',
payload: { title: 'Tag A' },
});
(tagA as any).timestamp = now - 500;
const tagB = clientB.createOperation({
actionType: '[Tag] Update' as ActionType,
opType: OpType.Update,
entityType: 'TAG',
entityId: 'tag-1',
payload: { title: 'Tag B' },
});
(tagB as any).timestamp = now - 500; // Same timestamp - tie
// Store all ops - remote ops with pendingApply flag
await storeService.append(taskA, 'local');
const taskBSeq = await storeService.append(taskB, 'remote', { pendingApply: true });
await storeService.append(projectA, 'local');
await storeService.append(projectB, 'remote');
await storeService.append(tagA, 'local');
const tagBSeq = await storeService.append(tagB, 'remote', { pendingApply: true });
// Apply LWW results:
// Task: remote wins - reject local, mark remote applied
await storeService.markRejected([taskA.id]);
await storeService.markApplied([taskBSeq]);
// Project: local wins - reject both
await storeService.markRejected([projectA.id, projectB.id]);
// Tag: tie (remote wins) - reject local, mark remote applied
await storeService.markRejected([tagA.id]);
await storeService.markApplied([tagBSeq]);
// Verify final states
const taskEntryA = await storeService.getOpById(taskA.id);
const taskEntryB = await storeService.getOpById(taskB.id);
const projectEntryA = await storeService.getOpById(projectA.id);
const projectEntryB = await storeService.getOpById(projectB.id);
const tagEntryA = await storeService.getOpById(tagA.id);
const tagEntryB = await storeService.getOpById(tagB.id);
// Task A rejected, Task B applied
expect(taskEntryA?.rejectedAt).toBeDefined();
expect(taskEntryB?.applicationStatus).toBe('applied');
// Both project ops rejected
expect(projectEntryA?.rejectedAt).toBeDefined();
expect(projectEntryB?.rejectedAt).toBeDefined();
// Tag A rejected, Tag B applied
expect(tagEntryA?.rejectedAt).toBeDefined();
expect(tagEntryB?.applicationStatus).toBe('applied');
});
});
});