Files
Codewalkers/apps/server/coordination/conflict-resolution-service.test.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

371 lines
13 KiB
TypeScript

/**
* 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<T extends DomainEvent>(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'];
await service.handleConflict(originalTask.id, conflicts);
// 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 update original task status to blocked', async () => {
const originalTask = await taskRepository.create({
phaseId: testPhaseId,
initiativeId: testInitiativeId,
name: 'Task To Block',
status: 'in_progress',
order: 1,
});
await agentRepository.create({
name: 'agent-block',
taskId: originalTask.id,
worktreeId: 'wt-block',
});
await service.handleConflict(originalTask.id, ['conflict.ts']);
// Check original task is blocked
const updatedTask = await taskRepository.findById(originalTask.id);
expect(updatedTask!.status).toBe('blocked');
});
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 still create task
await expect(serviceNoMsg.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// 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 still create task
await expect(serviceNoEvents.handleConflict(originalTask.id, ['test.ts']))
.resolves.not.toThrow();
// 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);
});
});
});