/** * ConflictResolutionService Tests * * Tests for the conflict resolution service that handles merge conflicts * by creating resolution tasks, updating statuses, and notifying agents. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DefaultConflictResolutionService } from './conflict-resolution-service.js'; import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js'; import { DrizzleAgentRepository } from '../db/repositories/drizzle/agent.js'; import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js'; import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js'; import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import type { DrizzleDatabase } from '../db/index.js'; import type { EventBus, DomainEvent } from '../events/types.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { MessageRepository } from '../db/repositories/message-repository.js'; // ============================================================================= // Test Helpers // ============================================================================= /** * Create a mock EventBus that captures emitted events. */ function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } { const emittedEvents: DomainEvent[] = []; return { emittedEvents, emit(event: T): void { emittedEvents.push(event); }, on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } // ============================================================================= // Tests // ============================================================================= describe('DefaultConflictResolutionService', () => { let db: DrizzleDatabase; let taskRepository: TaskRepository; let agentRepository: AgentRepository; let messageRepository: MessageRepository; let eventBus: EventBus & { emittedEvents: DomainEvent[] }; let service: DefaultConflictResolutionService; let testPhaseId: string; let testInitiativeId: string; beforeEach(async () => { // Set up test database db = createTestDatabase(); taskRepository = new DrizzleTaskRepository(db); agentRepository = new DrizzleAgentRepository(db); messageRepository = new DrizzleMessageRepository(db); // Create required hierarchy for tasks const initiativeRepo = new DrizzleInitiativeRepository(db); const phaseRepo = new DrizzlePhaseRepository(db); const initiative = await initiativeRepo.create({ name: 'Test Initiative', }); testInitiativeId = initiative.id; const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Test Phase', }); testPhaseId = phase.id; // Create mocks eventBus = createMockEventBus(); // Create service service = new DefaultConflictResolutionService( taskRepository, agentRepository, messageRepository, eventBus ); }); // =========================================================================== // handleConflict() Tests // =========================================================================== describe('handleConflict', () => { it('should create conflict resolution task with correct properties', async () => { // Create original task const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Original Task', description: 'Original task description', priority: 'medium', order: 1, }); // Create agent for task const agent = await agentRepository.create({ name: 'agent-test', taskId: originalTask.id, worktreeId: 'wt-test', }); const conflicts = ['src/file1.ts', 'src/file2.ts']; const result = await service.handleConflict(originalTask.id, conflicts); // Should return the created task expect(result).toBeDefined(); expect(result!.name).toBe('Resolve conflicts: Original Task'); // Check resolution task was created const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTask).toBeDefined(); expect(resolutionTask!.name).toBe('Resolve conflicts: Original Task'); expect(resolutionTask!.priority).toBe('high'); expect(resolutionTask!.type).toBe('auto'); expect(resolutionTask!.status).toBe('pending'); expect(resolutionTask!.order).toBe(originalTask.order + 1); expect(resolutionTask!.phaseId).toBe(testPhaseId); expect(resolutionTask!.initiativeId).toBe(testInitiativeId); expect(resolutionTask!.parentTaskId).toBe(originalTask.parentTaskId); // Check description contains conflict files expect(resolutionTask!.description).toContain('src/file1.ts'); expect(resolutionTask!.description).toContain('src/file2.ts'); expect(resolutionTask!.description).toContain('Original Task'); }); it('should NOT block original task (it stays at its current status)', async () => { const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Task To Not Block', status: 'completed', order: 1, }); await agentRepository.create({ name: 'agent-block', taskId: originalTask.id, worktreeId: 'wt-block', }); await service.handleConflict(originalTask.id, ['conflict.ts']); // Original task should remain completed (not blocked) const updatedTask = await taskRepository.findById(originalTask.id); expect(updatedTask!.status).toBe('completed'); }); it('should return null and skip creation if duplicate resolution task exists', async () => { const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Dedup Task', order: 1, }); await agentRepository.create({ name: 'agent-dedup', taskId: originalTask.id, worktreeId: 'wt-dedup', }); // First call creates the resolution task const first = await service.handleConflict(originalTask.id, ['conflict.ts']); expect(first).toBeDefined(); // Second call should return null (dedup) const second = await service.handleConflict(originalTask.id, ['conflict.ts']); expect(second).toBeNull(); // Only one resolution task should exist const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTasks = tasks.filter(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTasks.length).toBe(1); }); it('should create message to agent about conflict', async () => { const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Message Task', order: 1, }); const agent = await agentRepository.create({ name: 'agent-msg', taskId: originalTask.id, worktreeId: 'wt-msg', }); const conflicts = ['conflict.ts']; await service.handleConflict(originalTask.id, conflicts); // Check message was created const messages = await messageRepository.findByRecipient('agent', agent.id); expect(messages.length).toBe(1); expect(messages[0].recipientType).toBe('agent'); expect(messages[0].recipientId).toBe(agent.id); expect(messages[0].senderType).toBe('user'); expect(messages[0].type).toBe('info'); expect(messages[0].requiresResponse).toBe(false); // Check message content expect(messages[0].content).toContain('Merge conflict detected'); expect(messages[0].content).toContain('Message Task'); expect(messages[0].content).toContain('conflict.ts'); expect(messages[0].content).toContain('Resolve conflicts: Message Task'); }); it('should emit TaskQueuedEvent for resolution task', async () => { const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Event Task', order: 1, }); await agentRepository.create({ name: 'agent-event', taskId: originalTask.id, worktreeId: 'wt-event', }); await service.handleConflict(originalTask.id, ['event.ts']); // Check TaskQueuedEvent was emitted expect(eventBus.emittedEvents.length).toBe(1); expect(eventBus.emittedEvents[0].type).toBe('task:queued'); const event = eventBus.emittedEvents[0] as any; expect(event.payload.priority).toBe('high'); expect(event.payload.dependsOn).toEqual([]); // Check taskId matches the created resolution task const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(event.payload.taskId).toBe(resolutionTask!.id); }); it('should work without messageRepository', async () => { // Create service without messageRepository const serviceNoMsg = new DefaultConflictResolutionService( taskRepository, agentRepository, undefined, // No message repository eventBus ); const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'No Message Task', order: 1, }); await agentRepository.create({ name: 'agent-no-msg', taskId: originalTask.id, worktreeId: 'wt-no-msg', }); // Should not throw and should return the created task const result = await serviceNoMsg.handleConflict(originalTask.id, ['test.ts']); expect(result).toBeDefined(); // Check resolution task was still created const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTask).toBeDefined(); }); it('should work without eventBus', async () => { // Create service without eventBus const serviceNoEvents = new DefaultConflictResolutionService( taskRepository, agentRepository, messageRepository, undefined // No event bus ); const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'No Events Task', order: 1, }); await agentRepository.create({ name: 'agent-no-events', taskId: originalTask.id, worktreeId: 'wt-no-events', }); // Should not throw and should return the created task const result = await serviceNoEvents.handleConflict(originalTask.id, ['test.ts']); expect(result).toBeDefined(); // Check resolution task was still created const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTask).toBeDefined(); }); it('should throw error when task not found', async () => { await expect(service.handleConflict('non-existent-id', ['test.ts'])) .rejects.toThrow('Original task not found: non-existent-id'); }); it('should throw error when no agent found for task', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Orphan Task', order: 1, }); await expect(service.handleConflict(task.id, ['test.ts'])) .rejects.toThrow(`No agent found for task: ${task.id}`); }); it('should handle multiple conflict files correctly', async () => { const originalTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Multi-Conflict Task', order: 1, }); await agentRepository.create({ name: 'agent-multi', taskId: originalTask.id, worktreeId: 'wt-multi', }); const conflicts = [ 'src/components/Header.tsx', 'src/utils/helpers.ts', 'package.json', 'README.md' ]; await service.handleConflict(originalTask.id, conflicts); // Check all conflict files are in the description const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTask!.description).toContain('src/components/Header.tsx'); expect(resolutionTask!.description).toContain('src/utils/helpers.ts'); expect(resolutionTask!.description).toContain('package.json'); expect(resolutionTask!.description).toContain('README.md'); }); it('should preserve parentTaskId from original task', async () => { // Create parent task first const parentTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, name: 'Parent Task', order: 1, }); // Create child task const childTask = await taskRepository.create({ phaseId: testPhaseId, initiativeId: testInitiativeId, parentTaskId: parentTask.id, name: 'Child Task', order: 2, }); await agentRepository.create({ name: 'agent-child', taskId: childTask.id, worktreeId: 'wt-child', }); await service.handleConflict(childTask.id, ['conflict.ts']); // Check resolution task has same parentTaskId const tasks = await taskRepository.findByPhaseId(testPhaseId); const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:')); expect(resolutionTask!.parentTaskId).toBe(parentTask.id); }); }); });