/** * RED PHASE Tests for Conflict Resolution * * Tests for detecting and resolving conflicts during sync. * These tests should FAIL until implementation is complete. */ import { describe, it, expect, vi } from 'vitest' import { resolveConflict, createConflictResolver, ConflictResolverBuilder, strategies, detectConflict, } from './conflict-resolver' import type { BaseRecord, Conflict, ConflictResolver } from '../types' interface Task extends BaseRecord { id: string title: string status: string priority: number updatedAt: number } describe('Conflict Detection', () => { describe('detectConflict()', () => { it('should detect update-update conflict', () => { const local: Partial = { status: 'in_progress' } const remote: Partial = { status: 'done' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict?.type).toBe('update-update') }) it('should detect update-delete conflict', () => { const local: Partial = { status: 'in_progress' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, } as Parameters>[0]) // Remote deleted - no remoteValue expect(conflict?.type).toBe('update-delete') }) it('should detect delete-update conflict', () => { const remote: Partial = { status: 'done' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, } as Parameters>[0]) // Local deleted - no localValue expect(conflict?.type).toBe('delete-update') }) it('should detect insert-insert conflict', () => { const local: Partial = { id: '1', title: 'Local Task', status: 'todo', priority: 1 } const remote: Partial = { id: '1', title: 'Remote Task', status: 'todo', priority: 2 } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, localTimestamp: 2000, remoteTimestamp: 2500, } as Parameters[0]) // No base = both inserted expect(conflict?.type).toBe('insert-insert') }) it('should return null when no conflict', () => { const local: Partial = { title: 'Updated' } const remote: Partial = { status: 'done' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } // Different fields modified = no conflict const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict).toBeNull() }) }) }) describe('Conflict Resolution Strategies', () => { const baseConflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'in_progress', priority: 2 }, remoteValue: { status: 'done', priority: 1 }, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } describe('strategies.localWins', () => { it('should return local value', async () => { const result = await strategies.localWins(baseConflict) expect(result?.status).toBe('in_progress') expect(result?.priority).toBe(2) }) it('should merge with base for complete record', async () => { const result = await strategies.localWins(baseConflict) expect(result?.id).toBe('1') expect(result?.title).toBe('Task') }) }) describe('strategies.remoteWins', () => { it('should return remote value', async () => { const result = await strategies.remoteWins(baseConflict) expect(result?.status).toBe('done') expect(result?.priority).toBe(1) }) it('should merge with base for complete record', async () => { const result = await strategies.remoteWins(baseConflict) expect(result?.id).toBe('1') expect(result?.title).toBe('Task') }) }) describe('strategies.latestWins', () => { it('should use remote when remote is newer', async () => { const result = await strategies.latestWins(baseConflict) expect(result?.status).toBe('done') // Remote timestamp is 2500 }) it('should use local when local is newer', async () => { const conflict = { ...baseConflict, localTimestamp: 3000 } const result = await strategies.latestWins(conflict) expect(result?.status).toBe('in_progress') }) }) describe('strategies.merge', () => { it('should merge non-conflicting fields', async () => { const conflict: Conflict = { ...baseConflict, localValue: { title: 'Local Title' }, remoteValue: { status: 'done' }, } const result = await strategies.merge(conflict) expect(result?.title).toBe('Local Title') expect(result?.status).toBe('done') }) it('should use latest for conflicting fields', async () => { const conflict: Conflict = { ...baseConflict, localValue: { status: 'in_progress' }, remoteValue: { status: 'done' }, localTimestamp: 2000, remoteTimestamp: 3000, } const result = await strategies.merge(conflict) expect(result?.status).toBe('done') // Remote is newer }) }) }) describe('resolveConflict()', () => { const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'in_progress' }, remoteValue: { status: 'done' }, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } it('should resolve with local-wins strategy', async () => { const result = await resolveConflict(conflict, 'local-wins') expect(result?.status).toBe('in_progress') }) it('should resolve with remote-wins strategy', async () => { const result = await resolveConflict(conflict, 'remote-wins') expect(result?.status).toBe('done') }) it('should resolve with latest-wins strategy', async () => { const result = await resolveConflict(conflict, 'latest-wins') expect(result?.status).toBe('done') }) it('should resolve with merge strategy', async () => { const result = await resolveConflict(conflict, 'merge') expect(result).toBeDefined() }) it('should return null for manual strategy', async () => { const result = await resolveConflict(conflict, 'manual') expect(result).toBeNull() }) it('should accept custom resolver function', async () => { const customResolver: ConflictResolver = () => ({ id: '1', title: 'Custom', status: 'custom', priority: 99, updatedAt: Date.now(), }) const result = await resolveConflict(conflict, customResolver) expect(result?.status).toBe('custom') }) }) describe('createConflictResolver()', () => { it('should return a resolver function', () => { const resolver = createConflictResolver('local-wins') expect(typeof resolver).toBe('function') }) it('should apply the specified strategy', async () => { const resolver = createConflictResolver('remote-wins') const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: '1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await resolver(conflict) expect(result?.status).toBe('remote') }) }) describe('ConflictResolverBuilder', () => { it('should create builder instance', () => { const builder = new ConflictResolverBuilder() expect(builder).toBeInstanceOf(ConflictResolverBuilder) }) it('should set default strategy', () => { const builder = new ConflictResolverBuilder() .defaultStrategy('local-wins') expect(builder).toBeInstanceOf(ConflictResolverBuilder) }) it('should set strategy for specific conflict type', () => { const builder = new ConflictResolverBuilder() .forConflictType('update-delete', 'local-wins') .forConflictType('delete-update', 'remote-wins') expect(builder).toBeInstanceOf(ConflictResolverBuilder) }) it('should set custom handler for specific record', () => { const handler = vi.fn() const builder = new ConflictResolverBuilder() .forRecord('important-task', handler) expect(builder).toBeInstanceOf(ConflictResolverBuilder) }) it('should build resolver function', () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('latest-wins') .build() expect(typeof resolver).toBe('function') }) it('should apply custom handler when matched', async () => { const customResult: Task = { id: 'important-task', title: 'Custom', status: 'special', priority: 100, updatedAt: Date.now(), } const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forRecord('important-task', () => customResult) .build() const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: 'important-task', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: 'important-task', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await resolver(conflict) expect(result?.status).toBe('special') }) it('should apply conflict type strategy when matched', async () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forConflictType('update-delete', 'local-wins') .build() const conflict = { type: 'update-delete', collectionId: 'tasks', recordId: '1', localValue: { status: 'local' }, baseValue: { id: '1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } as Conflict // Remote deleted - no remoteValue const result = await resolver(conflict) expect(result?.status).toBe('local') }) it('should fall back to default strategy', async () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('local-wins') .forConflictType('insert-insert', 'remote-wins') .build() const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: '1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await resolver(conflict) expect(result?.status).toBe('local') }) }) describe('Edge cases', () => { it('should handle null local value (delete)', async () => { const conflict = { type: 'delete-update', collectionId: 'tasks', recordId: '1', remoteValue: { status: 'done' }, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } as Conflict // Local deleted - no localValue const result = await resolveConflict(conflict, 'local-wins') expect(result).toBeNull() // Local deleted, so result is null }) it('should handle null remote value (delete)', async () => { const conflict = { type: 'update-delete', collectionId: 'tasks', recordId: '1', localValue: { status: 'in_progress' }, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } as Conflict // Remote deleted - no remoteValue const result = await resolveConflict(conflict, 'remote-wins') expect(result).toBeNull() // Remote deleted, so result is null }) it('should handle missing base value (new records)', async () => { const conflict = { type: 'insert-insert', collectionId: 'tasks', recordId: '1', localValue: { id: '1', title: 'Local', status: 'todo', priority: 1, updatedAt: 2000 }, remoteValue: { id: '1', title: 'Remote', status: 'done', priority: 2, updatedAt: 2500 }, localTimestamp: 2000, remoteTimestamp: 2500, } as Conflict // No base = both inserted const result = await resolveConflict(conflict, 'latest-wins') expect(result?.title).toBe('Remote') }) }) describe('Conflict Detection - Additional Cases', () => { it('should return null when both modified same field to same value', () => { const local: Partial = { status: 'done' } const remote: Partial = { status: 'done' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } // Both changed to same value - no conflict const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict).toBeNull() }) it('should return null when only one side modified', () => { const local: Partial = { status: 'done' } const remote: Partial = { title: 'Same Title' } const base: Task = { id: '1', title: 'Same Title', status: 'todo', priority: 1, updatedAt: 1000 } // Remote didn't actually change (same as base) const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict).toBeNull() }) it('should include conflict info in result', () => { const local: Partial = { status: 'in_progress' } const remote: Partial = { status: 'done' } const base: Task = { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: local, remoteValue: remote, baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict).not.toBeNull() expect(conflict?.collectionId).toBe('tasks') expect(conflict?.recordId).toBe('1') expect(conflict?.localValue).toEqual(local) expect(conflict?.remoteValue).toEqual(remote) expect(conflict?.baseValue).toEqual(base) expect(conflict?.localTimestamp).toBe(2000) expect(conflict?.remoteTimestamp).toBe(2500) }) it('should handle numeric record IDs', () => { interface NumericTask extends BaseRecord { id: number name: string } const conflict = detectConflict({ collectionId: 'tasks', recordId: 123, localValue: { name: 'Local' }, remoteValue: { name: 'Remote' }, baseValue: { id: 123, name: 'Original' }, localTimestamp: 2000, remoteTimestamp: 2500, }) expect(conflict?.type).toBe('update-update') expect(conflict?.recordId).toBe(123) }) it('should handle conflicts with many fields', () => { interface ComplexTask extends BaseRecord { id: string field1: string field2: string field3: string field4: number field5: boolean } const base: ComplexTask = { id: '1', field1: 'a', field2: 'b', field3: 'c', field4: 1, field5: true, } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: { field1: 'x', field3: 'y' }, // Changed fields 1 and 3 remoteValue: { field1: 'z', field4: 2 }, // Changed fields 1 and 4 baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) // Conflict because field1 was changed by both to different values expect(conflict?.type).toBe('update-update') }) it('should not detect conflict when changes are in different fields', () => { interface ComplexTask extends BaseRecord { id: string field1: string field2: string field3: string } const base: ComplexTask = { id: '1', field1: 'a', field2: 'b', field3: 'c', } const conflict = detectConflict({ collectionId: 'tasks', recordId: '1', localValue: { field1: 'x' }, // Changed field1 remoteValue: { field2: 'y' }, // Changed field2 baseValue: base, localTimestamp: 2000, remoteTimestamp: 2500, }) // No conflict because different fields were changed expect(conflict).toBeNull() }) }) describe('strategies.merge - Detailed Tests', () => { it('should use local change when only local modified a field', async () => { const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { title: 'New Title' }, remoteValue: { status: 'done' }, baseValue: { id: '1', title: 'Old Title', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await strategies.merge(conflict) expect(result?.title).toBe('New Title') // Local change expect(result?.status).toBe('done') // Remote change }) it('should preserve base values for unchanged fields', async () => { const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { title: 'New Title' }, remoteValue: { status: 'done' }, baseValue: { id: '1', title: 'Old Title', status: 'todo', priority: 5, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await strategies.merge(conflict) expect(result?.priority).toBe(5) // Unchanged from base expect(result?.id).toBe('1') // Unchanged from base }) it('should handle empty local value in merge', async () => { const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: {}, remoteValue: { status: 'done' }, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await strategies.merge(conflict) expect(result?.status).toBe('done') expect(result?.title).toBe('Task') }) it('should handle empty remote value in merge', async () => { const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'in_progress' }, remoteValue: {}, baseValue: { id: '1', title: 'Task', status: 'todo', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await strategies.merge(conflict) expect(result?.status).toBe('in_progress') }) }) describe('ConflictResolverBuilder - Advanced', () => { it('should support chaining multiple forConflictType calls', () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('latest-wins') .forConflictType('update-update', 'merge') .forConflictType('update-delete', 'local-wins') .forConflictType('delete-update', 'remote-wins') .forConflictType('insert-insert', 'latest-wins') .build() expect(typeof resolver).toBe('function') }) it('should support custom resolver for conflict type', async () => { const customResolver: ConflictResolver = (conflict) => { // Custom logic: always pick local with remote timestamp return { ...conflict.baseValue!, ...conflict.localValue, updatedAt: conflict.remoteTimestamp, } as Task } const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forConflictType('update-update', customResolver) .build() const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'local-status' }, remoteValue: { status: 'remote-status' }, baseValue: { id: '1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 3000, } const result = await resolver(conflict) expect(result?.status).toBe('local-status') // Local value expect(result?.updatedAt).toBe(3000) // Remote timestamp }) it('should support multiple record-specific handlers', async () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forRecord('priority-1', () => ({ id: 'priority-1', title: 'Priority 1', status: 'forced', priority: 1, updatedAt: Date.now(), })) .forRecord('priority-2', () => ({ id: 'priority-2', title: 'Priority 2', status: 'forced', priority: 2, updatedAt: Date.now(), })) .build() const conflict1: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: 'priority-1', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: 'priority-1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const conflict2: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: 'priority-2', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: 'priority-2', title: 'Task', status: 'base', priority: 2, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result1 = await resolver(conflict1) const result2 = await resolver(conflict2) expect(result1?.status).toBe('forced') expect(result1?.priority).toBe(1) expect(result2?.status).toBe('forced') expect(result2?.priority).toBe(2) }) it('should prioritize record handler over conflict type strategy', async () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forConflictType('update-update', 'local-wins') .forRecord('special', () => ({ id: 'special', title: 'Special', status: 'special-handler', priority: 999, updatedAt: Date.now(), })) .build() const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: 'special', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: 'special', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await resolver(conflict) expect(result?.status).toBe('special-handler') // Record handler wins }) }) describe('Async Resolution', () => { it('should support async custom resolver', async () => { const asyncResolver: ConflictResolver = async (conflict) => { // Simulate async operation await new Promise(resolve => setTimeout(resolve, 10)) return { ...conflict.baseValue!, ...conflict.localValue, } as Task } const result = await resolveConflict( { type: 'update-update', collectionId: 'tasks', recordId: '1', localValue: { status: 'async-resolved' }, remoteValue: { status: 'remote' }, baseValue: { id: '1', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, }, asyncResolver ) expect(result?.status).toBe('async-resolved') }) it('should support async record handler in builder', async () => { const resolver = new ConflictResolverBuilder() .defaultStrategy('remote-wins') .forRecord('async-record', async () => { await new Promise(resolve => setTimeout(resolve, 10)) return { id: 'async-record', title: 'Async', status: 'async-resolved', priority: 1, updatedAt: Date.now(), } }) .build() const conflict: Conflict = { type: 'update-update', collectionId: 'tasks', recordId: 'async-record', localValue: { status: 'local' }, remoteValue: { status: 'remote' }, baseValue: { id: 'async-record', title: 'Task', status: 'base', priority: 1, updatedAt: 1000 }, localTimestamp: 2000, remoteTimestamp: 2500, } const result = await resolver(conflict) expect(result?.status).toBe('async-resolved') }) })